Skip to content

Commit 9c7460f

Browse files
committed
fix(npm): preserve occ executable permissions
1 parent f9f1863 commit 9c7460f

11 files changed

Lines changed: 181 additions & 1 deletion

File tree

.github/workflows/publish-npm.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ jobs:
9898
chmod +x packages/cli-node-linux-x64-musl/bin/occ
9999
chmod +x packages/cli-node-linux-arm64-musl/bin/occ
100100
101+
- name: Verify packaged binary permissions
102+
run: bash scripts/verify-cli-node-pack-perms.sh
103+
101104
- name: Build Node packages
102105
run: |
103106
pnpm -C packages/cli-node build

packages/cli-node-darwin-arm64/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"homepage": "https://github.com/pRizz/opencode-cloud",
1313
"os": ["darwin"],
1414
"cpu": ["arm64"],
15+
"publishConfig": {
16+
"executableFiles": ["bin/occ"]
17+
},
1518
"files": ["bin"],
1619
"main": "index.js"
1720
}

packages/cli-node-darwin-x64/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
"homepage": "https://github.com/pRizz/opencode-cloud",
1313
"os": ["darwin"],
1414
"cpu": ["x64"],
15+
"publishConfig": {
16+
"executableFiles": ["bin/occ"]
17+
},
1518
"files": ["bin"],
1619
"main": "index.js"
1720
}

packages/cli-node-linux-arm64-musl/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"os": ["linux"],
1414
"cpu": ["arm64"],
1515
"libc": ["musl"],
16+
"publishConfig": {
17+
"executableFiles": ["bin/occ"]
18+
},
1619
"files": ["bin"],
1720
"main": "index.js"
1821
}

packages/cli-node-linux-arm64/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"os": ["linux"],
1414
"cpu": ["arm64"],
1515
"libc": ["glibc"],
16+
"publishConfig": {
17+
"executableFiles": ["bin/occ"]
18+
},
1619
"files": ["bin"],
1720
"main": "index.js"
1821
}

packages/cli-node-linux-x64-musl/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"os": ["linux"],
1414
"cpu": ["x64"],
1515
"libc": ["musl"],
16+
"publishConfig": {
17+
"executableFiles": ["bin/occ"]
18+
},
1619
"files": ["bin"],
1720
"main": "index.js"
1821
}

packages/cli-node-linux-x64/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"os": ["linux"],
1414
"cpu": ["x64"],
1515
"libc": ["glibc"],
16+
"publishConfig": {
17+
"executableFiles": ["bin/occ"]
18+
},
1619
"files": ["bin"],
1720
"main": "index.js"
1821
}

packages/cli-node/src/index.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { existsSync } from 'fs';
1111
import { fileURLToPath } from 'url';
1212
import { dirname, join } from 'path';
1313
import { createRequire } from 'module';
14+
import { ensureExecutable } from './permissions.js';
1415

1516
const require = createRequire(import.meta.url);
1617
const scriptDir = dirname(fileURLToPath(import.meta.url));
@@ -101,6 +102,8 @@ if (!binaryPath) {
101102
process.exit(1);
102103
}
103104

105+
ensureExecutable(binaryPath);
106+
104107
const child = spawn(binaryPath, process.argv.slice(2), {
105108
stdio: 'inherit',
106109
});
@@ -109,8 +112,13 @@ child.on('close', (code: number | null) => {
109112
process.exit(code ?? 1);
110113
});
111114

112-
child.on('error', (err: Error) => {
115+
child.on('error', (err: NodeJS.ErrnoException) => {
113116
console.error(`Error: Failed to spawn binary at ${binaryPath}`);
117+
if (err.code === 'EACCES') {
118+
console.error(
119+
`Permission denied executing binary. Try: chmod +x ${binaryPath}\n`
120+
);
121+
}
114122
console.error(`Error: ${err.message}\n`);
115123
console.error('Try reinstalling: npm install opencode-cloud');
116124
process.exit(1);
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as fs from 'fs';
2+
import { join } from 'path';
3+
import { tmpdir } from 'os';
4+
import { describe, expect, it } from 'vitest';
5+
import { ensureExecutable } from './permissions.js';
6+
7+
function makeTempBinary(mode: number): { dir: string; path: string } {
8+
const dir = fs.mkdtempSync(join(tmpdir(), 'occ-permissions-'));
9+
const path = join(dir, 'occ');
10+
fs.writeFileSync(path, '#!/usr/bin/env sh\necho ok\n', 'utf8');
11+
fs.chmodSync(path, mode);
12+
return { dir, path };
13+
}
14+
15+
describe('ensureExecutable', () => {
16+
it('sets execute permissions when the file is not executable', () => {
17+
const tmp = makeTempBinary(0o644);
18+
19+
ensureExecutable(tmp.path);
20+
21+
const mode = fs.statSync(tmp.path).mode & 0o777;
22+
expect(mode & 0o111).not.toBe(0);
23+
24+
fs.rmSync(tmp.dir, { recursive: true, force: true });
25+
});
26+
27+
it('keeps executable files executable', () => {
28+
const tmp = makeTempBinary(0o755);
29+
30+
ensureExecutable(tmp.path);
31+
32+
const mode = fs.statSync(tmp.path).mode & 0o777;
33+
expect(mode & 0o111).not.toBe(0);
34+
35+
fs.rmSync(tmp.dir, { recursive: true, force: true });
36+
});
37+
38+
it('does not throw when chmod fails', () => {
39+
const tmp = makeTempBinary(0o644);
40+
const throwingFs = {
41+
statSync: (filePath: string) => fs.statSync(filePath),
42+
chmodSync: () => {
43+
throw new Error('chmod failed');
44+
},
45+
};
46+
47+
expect(() => ensureExecutable(tmp.path, throwingFs)).not.toThrow();
48+
const mode = fs.statSync(tmp.path).mode & 0o777;
49+
expect(mode & 0o111).toBe(0);
50+
51+
fs.rmSync(tmp.dir, { recursive: true, force: true });
52+
});
53+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import * as fs from 'fs';
2+
3+
type PermissionFs = {
4+
statSync: (filePath: string) => { mode: number };
5+
chmodSync: (filePath: string, mode: number) => void;
6+
};
7+
8+
/**
9+
* Best-effort permission fix for packaged binaries.
10+
* If execute bits are missing, attempt to restore them.
11+
*/
12+
export function ensureExecutable(
13+
filePath: string,
14+
fileSystem: PermissionFs = fs
15+
): void {
16+
try {
17+
const mode = fileSystem.statSync(filePath).mode;
18+
if ((mode & 0o111) === 0) {
19+
fileSystem.chmodSync(filePath, 0o755);
20+
}
21+
} catch {
22+
// Do not hard-fail here; spawn() will report a concrete error.
23+
}
24+
}

0 commit comments

Comments
 (0)