Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 42 additions & 9 deletions packages/desktop/scripts/ensure-node-pty-exec.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,16 @@
#!/usr/bin/env node
import { chmodSync, existsSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';

const SHIPPED_ARCH = 'darwin-arm64';

const prebuildsDirFor = (resourcesDir) =>
join(resourcesDir, 'app.asar.unpacked', 'node_modules', 'node-pty', 'prebuilds');

export function ensureNodePtySpawnHelperExecutable(resourcesDir) {
const prebuildsDir = prebuildsDirFor(resourcesDir);
function chmodSpawnHelpersUnderPrebuilds(prebuildsDir, remediation) {
const requiredHelper = join(prebuildsDir, SHIPPED_ARCH, 'spawn-helper');
if (!existsSync(requiredHelper)) {
throw new Error(
`[ensure-node-pty-exec] node-pty ${SHIPPED_ARCH} spawn-helper missing at ${requiredHelper}. ` +
`Confirm node-pty is a desktop dependency and the '**/node-pty/prebuilds/**' asarUnpack ` +
`rule in electron-builder.yml unpacked it — without an executable spawn-helper on the real ` +
`filesystem, pty.fork() fails at runtime with "posix_spawnp failed".`,
remediation,
);
}

Expand All @@ -29,3 +24,41 @@ export function ensureNodePtySpawnHelperExecutable(resourcesDir) {
}
return chmodded;
}

function resolveNodePtyDir() {
const require = createRequire(import.meta.url);
return dirname(require.resolve('node-pty/package.json'));
}

export function ensureNodePtySpawnHelperExecutable(resourcesDir) {
const prebuildsDir = join(
resourcesDir,
'app.asar.unpacked',
'node_modules',
'node-pty',
'prebuilds',
);
return chmodSpawnHelpersUnderPrebuilds(
prebuildsDir,
`Confirm node-pty is a desktop dependency and the '**/node-pty/prebuilds/**' asarUnpack ` +
`rule in electron-builder.yml unpacked it — without an executable spawn-helper on the real ` +
`filesystem, pty.fork() fails at runtime with "posix_spawnp failed".`,
);
}

export function ensureNodePtySpawnHelperExecutableInNodeModules(nodePtyDir = resolveNodePtyDir()) {
return chmodSpawnHelpersUnderPrebuilds(
join(nodePtyDir, 'prebuilds'),
`Confirm node-pty is installed (it is a desktop dependency) and 'bun install' did not run ` +
`with --ignore-scripts — without an executable spawn-helper, pty.fork() fails at runtime ` +
`with "posix_spawnp failed" and the in-app terminal cannot spawn a shell.`,
);
}

export function ensureNodePtySpawnHelperExecutableInNodeModulesSafe(nodePtyDir) {
try {
return { ok: true, chmodded: ensureNodePtySpawnHelperExecutableInNodeModules(nodePtyDir) };
} catch (err) {
return { ok: false, error: err instanceof Error ? err : new Error(String(err)) };
}
}
12 changes: 12 additions & 0 deletions packages/desktop/scripts/postinstall.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { ensureNodePtySpawnHelperExecutableInNodeModulesSafe } from './ensure-node-pty-exec.mjs';

const spawnHelper = ensureNodePtySpawnHelperExecutableInNodeModulesSafe();
if (spawnHelper.ok) {
console.log(
`[desktop postinstall] node-pty spawn-helper marked executable (${spawnHelper.chmodded.length} file(s))`,
);
} else {
console.warn(
`[desktop postinstall] could not make node-pty spawn-helper executable: ${spawnHelper.error.message}`,
);
}

if (process.env.ELECTRON_SKIP_REBUILD === '1') {
console.log(
Expand Down
75 changes: 74 additions & 1 deletion packages/desktop/tests/unit/ensure-node-pty-exec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { afterEach, describe, expect, test } from 'bun:test';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ensureNodePtySpawnHelperExecutable } from '../../scripts/ensure-node-pty-exec.mjs';
import {
ensureNodePtySpawnHelperExecutable,
ensureNodePtySpawnHelperExecutableInNodeModules,
ensureNodePtySpawnHelperExecutableInNodeModulesSafe,
} from '../../scripts/ensure-node-pty-exec.mjs';

const tmpRoots: string[] = [];

Expand Down Expand Up @@ -67,3 +71,72 @@ describe('ensureNodePtySpawnHelperExecutable', () => {
);
});
});

function nodeModulesHelperPath(nodePtyDir: string, arch: string): string {
return join(nodePtyDir, 'prebuilds', arch, 'spawn-helper');
}

function makeNodeModulesFixture(archDirs: string[]): string {
const root = mkdtempSync(join(tmpdir(), 'ok-nodepty-dev-exec-'));
tmpRoots.push(root);
const nodePtyDir = join(root, 'node_modules', 'node-pty');
for (const arch of archDirs) {
const helper = nodeModulesHelperPath(nodePtyDir, arch);
mkdirSync(join(helper, '..'), { recursive: true });
writeFileSync(helper, 'fake-mach-o');
chmodSync(helper, 0o644);
}
return nodePtyDir;
}

describe('ensureNodePtySpawnHelperExecutableInNodeModules', () => {
test('promotes the shipped darwin-arm64 spawn-helper from 0644 to executable 0755', () => {
const nodePtyDir = makeNodeModulesFixture(['darwin-arm64']);
const helper = nodeModulesHelperPath(nodePtyDir, 'darwin-arm64');
expect(statSync(helper).mode & 0o111).toBe(0); // no execute bits to start

const chmodded = ensureNodePtySpawnHelperExecutableInNodeModules(nodePtyDir);

expect(chmodded).toContain(helper);
expect(statSync(helper).mode & 0o777).toBe(0o755);
});

test('chmods every prebuild arch present in node_modules, not just the shipped one', () => {
const nodePtyDir = makeNodeModulesFixture(['darwin-arm64', 'darwin-x64']);

const chmodded = ensureNodePtySpawnHelperExecutableInNodeModules(nodePtyDir);

expect(chmodded.length).toBe(2);
for (const arch of ['darwin-arm64', 'darwin-x64']) {
expect(statSync(nodeModulesHelperPath(nodePtyDir, arch)).mode & 0o777).toBe(0o755);
}
});

test('throws when the shipped darwin-arm64 spawn-helper is absent (broken install is a hard error)', () => {
const nodePtyDir = makeNodeModulesFixture(['darwin-x64']); // arm64 helper missing
expect(() => ensureNodePtySpawnHelperExecutableInNodeModules(nodePtyDir)).toThrow(
/darwin-arm64 spawn-helper missing/,
);
});
});

describe('ensureNodePtySpawnHelperExecutableInNodeModulesSafe', () => {
test('returns ok + chmodded on a healthy install and actually flips the bit', () => {
const nodePtyDir = makeNodeModulesFixture(['darwin-arm64']);
const result = ensureNodePtySpawnHelperExecutableInNodeModulesSafe(nodePtyDir);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.chmodded).toContain(nodeModulesHelperPath(nodePtyDir, 'darwin-arm64'));
}
expect(statSync(nodeModulesHelperPath(nodePtyDir, 'darwin-arm64')).mode & 0o777).toBe(0o755);
});

test('does NOT throw when the shipped helper is absent — returns ok:false so postinstall stays exit-0', () => {
const nodePtyDir = makeNodeModulesFixture(['darwin-x64']);
const result = ensureNodePtySpawnHelperExecutableInNodeModulesSafe(nodePtyDir);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.message).toMatch(/darwin-arm64 spawn-helper missing/);
}
});
});