Skip to content

Commit f01101b

Browse files
amikofalvyinkeep-oss-sync[bot]
authored andcommitted
chore(cli): extract shared detached-spawn helper (#2729)
* chore(cli): extract shared detached-spawn helper Four CLI sites copy-pasted the detached LaunchServices spawn recipe (detached, stdio ignore, unref, ELECTRON_RUN_AS_NODE scrubbed so the launched GUI does not boot as a headless Node host): launchDesktop, ok open, the single-file open path, and ok bug-report's Finder reveal. Consolidate into spawnDetachedScrubbed in cli/src/utils/detached-spawn.ts with the scrub encoded once. bug-report previously inherited the parent env unscrubbed; it now goes through the same scrub (a no-op for the Finder reveal target). * chore(cli): make scrubElectronRunAsNode module-private Post-consolidation the only remaining caller of scrubElectronRunAsNode is spawnDetachedScrubbed in the same file, so the export no longer earns its place. Drop the export and the two direct unit tests; the scrub and non-mutation contract stays pinned behaviorally through the spawnDetachedScrubbed env tests (explicit-env scrub + process.env default non-mutation). No coverage lost, narrower module surface. Addresses review feedback on #2729. GitOrigin-RevId: 558ec9b093d1b23f1b0ab90de8a65f4e959ea10a
1 parent adffdc0 commit f01101b

7 files changed

Lines changed: 140 additions & 87 deletions

File tree

packages/cli/src/commands/bug-report.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
import { spawn as childSpawn } from 'node:child_process';
21
import { platform } from 'node:os';
3-
import { withHiddenWindowsConsole } from '@inkeep/open-knowledge-server';
42
import { Command } from 'commander';
53
import { getCliLogger } from '../cli.ts';
64
import { collectReportBundle } from '../report-bundle.ts';
5+
import { spawnDetachedScrubbed } from '../utils/detached-spawn.ts';
76
import { defaultBugReportZipPath } from './bug-report-bundle.ts';
87

98
export function bugReportCommand(): Command {
@@ -31,11 +30,7 @@ export function bugReportCommand(): Command {
3130

3231
if (opts.reveal && platform() === 'darwin') {
3332
try {
34-
childSpawn(
35-
'/usr/bin/open',
36-
['-R', zipPath],
37-
withHiddenWindowsConsole({ detached: true, stdio: 'ignore' as const }),
38-
).unref();
33+
spawnDetachedScrubbed('/usr/bin/open', ['-R', zipPath]);
3934
} catch {}
4035
}
4136
} catch (err) {

packages/cli/src/commands/desktop-dispatch.ts

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
* and the caller falls through to the existing `ok start` flow.
1111
*/
1212

13-
import type { spawn as NativeSpawn, SpawnOptions } from 'node:child_process';
13+
import type { spawn as NativeSpawn } from 'node:child_process';
1414
import { statSync } from 'node:fs';
1515
import { homedir } from 'node:os';
1616
import { join } from 'node:path';
17+
import { spawnDetachedScrubbed } from '../utils/detached-spawn.ts';
1718

1819
/**
1920
* macOS bundle identifier for the desktop app. Reused for protocol
@@ -222,21 +223,7 @@ export function launchDesktop(deps: LaunchDeps): void {
222223
log(
223224
'Launching OpenKnowledge desktop (use `ok start` for the browser server, or `OK_FORCE_BROWSER=1` to always skip)',
224225
);
225-
// Scrub `ELECTRON_RUN_AS_NODE` from the spawned `open`'s env. The CLI
226-
// wrapper (`Contents/Resources/cli/bin/ok.sh`) sets it to 1 so the bundled
227-
// Electron binary acts as a Node host; LaunchServices propagates the
228-
// caller's env into the desktop process it spawns, and the desktop
229-
// Electron main process sees `ELECTRON_RUN_AS_NODE=1`, runs as a headless
230-
// Node host with no script, and exits immediately. Symptom: the
231-
// "Launching OpenKnowledge desktop" line prints but no GUI appears.
232-
const env = { ...process.env };
233-
delete env.ELECTRON_RUN_AS_NODE;
234-
const child = deps.spawn('open', ['-b', DESKTOP_BUNDLE_ID], {
235-
detached: true,
236-
stdio: 'ignore',
237-
env,
238-
} satisfies SpawnOptions);
239-
child.unref();
226+
spawnDetachedScrubbed('open', ['-b', DESKTOP_BUNDLE_ID], { spawn: deps.spawn });
240227
}
241228

242229
/**

packages/cli/src/commands/open.test.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from 'bun:test';
2-
import { createRealOpenDeps, type OpenDeps, runOpen, scrubElectronRunAsNode } from './open.ts';
2+
import { createRealOpenDeps, type OpenDeps, runOpen } from './open.ts';
33

44
function makeDeps(overrides: Partial<OpenDeps> = {}): {
55
deps: OpenDeps;
@@ -224,17 +224,3 @@ describe('createRealOpenDeps wiring', () => {
224224
expect(deps.detectBundlePath()).toBe('/Applications/OpenKnowledge.app');
225225
});
226226
});
227-
228-
describe('scrubElectronRunAsNode', () => {
229-
test('removes ELECTRON_RUN_AS_NODE so the spawned target does not inherit it', () => {
230-
const scrubbed = scrubElectronRunAsNode({ ELECTRON_RUN_AS_NODE: '1', PATH: '/usr/bin' });
231-
expect(scrubbed.ELECTRON_RUN_AS_NODE).toBeUndefined();
232-
expect(scrubbed.PATH).toBe('/usr/bin');
233-
});
234-
235-
test('does not mutate the input env', () => {
236-
const input = { ELECTRON_RUN_AS_NODE: '1' };
237-
scrubElectronRunAsNode(input);
238-
expect(input.ELECTRON_RUN_AS_NODE).toBe('1');
239-
});
240-
});

packages/cli/src/commands/open.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@
2727
* non-TTY/headless invocations (an agent shelling out). The verb spawns its own
2828
* `open "<url>"` (LaunchServices) rather than `launchDesktop`.
2929
*/
30-
import { spawn as nodeSpawn } from 'node:child_process';
3130
import { statSync } from 'node:fs';
3231
import { join, resolve } from 'node:path';
3332
import { MANAGED_ARTIFACT_SCOPES, type SkillScope } from '@inkeep/open-knowledge-core';
@@ -37,9 +36,9 @@ import {
3736
encodeSkillRoute,
3837
resolveLockDir,
3938
resolveUiInfo,
40-
withHiddenWindowsConsole,
4139
} from '@inkeep/open-knowledge-server';
4240
import { Command } from 'commander';
41+
import { spawnDetachedScrubbed } from '../utils/detached-spawn.ts';
4342
import { createRealDetectDeps, type DetectResult, detectDesktop } from './desktop-dispatch.ts';
4443

4544
export interface OpenOptions {
@@ -72,19 +71,6 @@ export interface OpenDeps {
7271
error: (message: string) => void;
7372
}
7473

75-
/**
76-
* Copy `process.env` minus `ELECTRON_RUN_AS_NODE`. The CLI wrapper sets that
77-
* var so the bundled Electron binary runs as a Node host; if the
78-
* LaunchServices-spawned target (the desktop app for `openknowledge://`, or the
79-
* browser for http) inherited it, it would start as a headless Node host with
80-
* no script and exit immediately. Mirrors `launchDesktop`.
81-
*/
82-
export function scrubElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
83-
const next = { ...env };
84-
delete next.ELECTRON_RUN_AS_NODE;
85-
return next;
86-
}
87-
8874
/**
8975
* Build the real side-effect surface. `detect` is injectable so the
9076
* `bundlePath ?? null` collapse can be unit-tested without a real macOS /
@@ -116,16 +102,7 @@ export function createRealOpenDeps(
116102
}
117103
},
118104
openTarget: (target) => {
119-
const child = nodeSpawn(
120-
'open',
121-
[target],
122-
withHiddenWindowsConsole({
123-
detached: true,
124-
stdio: 'ignore' as const,
125-
env: scrubElectronRunAsNode(process.env),
126-
}),
127-
);
128-
child.unref();
105+
spawnDetachedScrubbed('open', [target]);
129106
},
130107
log: (message) => process.stdout.write(`${message}\n`),
131108
error: (message) => process.stderr.write(`${message}\n`),

packages/cli/src/commands/single-file-open.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
* a tab, and removes the temp projectDir on teardown.
1414
*/
1515

16-
import { spawn as nodeSpawn } from 'node:child_process';
1716
import { existsSync } from 'node:fs';
1817
import { rm } from 'node:fs/promises';
1918
import { resolve } from 'node:path';
@@ -24,8 +23,8 @@ import {
2423
SingleFileNotFoundError,
2524
SingleFileNotMarkdownError,
2625
type SingleFileOpenPlan,
27-
withHiddenWindowsConsole,
2826
} from '@inkeep/open-knowledge-server';
27+
import { spawnDetachedScrubbed } from '../utils/detached-spawn.ts';
2928
import { createRealDetectDeps, type DetectResult, detectDesktop } from './desktop-dispatch.ts';
3029
import { createRealOpenDeps, runOpen } from './open.ts';
3130

@@ -53,35 +52,14 @@ export interface SingleFileOpenDeps {
5352
error: (message: string) => void;
5453
}
5554

56-
/**
57-
* Copy `process.env` minus `ELECTRON_RUN_AS_NODE` — mirrors `ok open`. The CLI
58-
* wrapper sets that var so the bundled Electron binary runs as a Node host;
59-
* leaking it to the LaunchServices-spawned target would start it headless and
60-
* exit immediately.
61-
*/
62-
function scrubElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
63-
const next = { ...env };
64-
delete next.ELECTRON_RUN_AS_NODE;
65-
return next;
66-
}
67-
6855
export function createRealSingleFileOpenDeps(
6956
detect: () => DetectResult = () => detectDesktop(createRealDetectDeps()),
7057
): SingleFileOpenDeps {
7158
return {
7259
prepare: prepareSingleFileOpen,
7360
detectBundlePath: () => detect().bundlePath ?? null,
7461
openTarget: (target) => {
75-
const child = nodeSpawn(
76-
'open',
77-
[target],
78-
withHiddenWindowsConsole({
79-
detached: true,
80-
stdio: 'ignore' as const,
81-
env: scrubElectronRunAsNode(process.env),
82-
}),
83-
);
84-
child.unref();
62+
spawnDetachedScrubbed('open', [target]);
8563
},
8664
runProjectOpen: (docName, projectRoot) =>
8765
runOpen(docName, { project: projectRoot }, createRealOpenDeps()),
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, expect, test } from 'bun:test';
2+
import type { SpawnOptions } from 'node:child_process';
3+
import { spawnDetachedScrubbed } from './detached-spawn.ts';
4+
5+
describe('spawnDetachedScrubbed', () => {
6+
interface Captured {
7+
command?: string;
8+
args?: readonly string[];
9+
opts?: SpawnOptions;
10+
}
11+
12+
function makeFakeSpawn(captured: Captured, onUnref: () => void) {
13+
return ((command: string, args: readonly string[], opts: SpawnOptions) => {
14+
captured.command = command;
15+
captured.args = args;
16+
captured.opts = opts;
17+
return { unref: onUnref };
18+
}) as unknown as NonNullable<Parameters<typeof spawnDetachedScrubbed>[2]>['spawn'];
19+
}
20+
21+
test('spawns detached, stdio:ignore, windowsHide, and unref()s the child', () => {
22+
const captured: Captured = {};
23+
let unrefCalled = false;
24+
spawnDetachedScrubbed('open', ['-b', 'com.example.app'], {
25+
spawn: makeFakeSpawn(captured, () => {
26+
unrefCalled = true;
27+
}),
28+
});
29+
30+
expect(captured.command).toBe('open');
31+
expect(captured.args).toEqual(['-b', 'com.example.app']);
32+
expect(captured.opts?.detached).toBe(true);
33+
expect(captured.opts?.stdio).toBe('ignore');
34+
expect(captured.opts?.windowsHide).toBe(true);
35+
expect(unrefCalled).toBe(true);
36+
});
37+
38+
test('scrubs ELECTRON_RUN_AS_NODE from the child env', () => {
39+
const captured: Captured = {};
40+
spawnDetachedScrubbed('open', ['target'], {
41+
spawn: makeFakeSpawn(captured, () => {}),
42+
env: { ELECTRON_RUN_AS_NODE: '1', PATH: '/usr/bin' },
43+
});
44+
45+
expect(captured.opts?.env).toBeDefined();
46+
expect(captured.opts?.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
47+
expect(captured.opts?.env?.PATH).toBe('/usr/bin');
48+
});
49+
50+
test('defaults env to a scrubbed copy of process.env', () => {
51+
const prevValue = process.env.ELECTRON_RUN_AS_NODE;
52+
process.env.ELECTRON_RUN_AS_NODE = '1';
53+
try {
54+
const captured: Captured = {};
55+
spawnDetachedScrubbed('open', ['target'], {
56+
spawn: makeFakeSpawn(captured, () => {}),
57+
});
58+
59+
expect(captured.opts?.env).not.toHaveProperty('ELECTRON_RUN_AS_NODE');
60+
// The scrub copies — this process keeps its own env untouched.
61+
expect(process.env.ELECTRON_RUN_AS_NODE).toBe('1');
62+
} finally {
63+
if (prevValue === undefined) delete process.env.ELECTRON_RUN_AS_NODE;
64+
else process.env.ELECTRON_RUN_AS_NODE = prevValue;
65+
}
66+
});
67+
});
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* Shared recipe for handing a target to the OS (LaunchServices `open`, Finder
3+
* reveal) as an independent child that survives this CLI process exiting.
4+
*
5+
* Every site that launches the desktop app (or any GUI target) from the CLI
6+
* must use this instead of a bare `spawn`: the packaged CLI wrapper
7+
* (`Contents/Resources/cli/bin/ok.sh`) sets `ELECTRON_RUN_AS_NODE=1` so the
8+
* bundled Electron binary acts as a Node host, and LaunchServices propagates
9+
* the caller's env into the process it spawns — an Electron GUI target that
10+
* inherits the var boots as a headless Node host with no script and exits
11+
* immediately. Symptom: the launch line prints but no window appears. The
12+
* scrub here is what keeps each call site from having to get that right
13+
* independently.
14+
*/
15+
import {
16+
type ChildProcess,
17+
type spawn as NativeSpawn,
18+
spawn as nativeSpawn,
19+
} from 'node:child_process';
20+
import { withHiddenWindowsConsole } from '@inkeep/open-knowledge-server';
21+
22+
/**
23+
* Copy `env` minus `ELECTRON_RUN_AS_NODE`. Non-mutating — the input env
24+
* (typically `process.env`) is left intact for this process. Module-private:
25+
* the scrub + non-mutation contract is pinned through `spawnDetachedScrubbed`.
26+
*/
27+
function scrubElectronRunAsNode(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
28+
const next = { ...env };
29+
delete next.ELECTRON_RUN_AS_NODE;
30+
return next;
31+
}
32+
33+
export interface SpawnDetachedScrubbedOptions {
34+
/** Override for tests — defaults to `node:child_process#spawn`. */
35+
spawn?: typeof NativeSpawn;
36+
/** Env to copy + scrub — defaults to `process.env`. */
37+
env?: NodeJS.ProcessEnv;
38+
}
39+
40+
/**
41+
* Spawn `command` detached (own process group, no stdio ties, `unref()`ed so
42+
* the CLI's event loop can drain) with `ELECTRON_RUN_AS_NODE` scrubbed from
43+
* the child env. Returns the child for callers that want a handle; most
44+
* fire-and-forget.
45+
*/
46+
export function spawnDetachedScrubbed(
47+
command: string,
48+
args: readonly string[],
49+
opts: SpawnDetachedScrubbedOptions = {},
50+
): ChildProcess {
51+
const spawnFn = opts.spawn ?? nativeSpawn;
52+
const child = spawnFn(
53+
command,
54+
[...args],
55+
withHiddenWindowsConsole({
56+
detached: true,
57+
stdio: 'ignore' as const,
58+
env: scrubElectronRunAsNode(opts.env ?? process.env),
59+
}),
60+
);
61+
child.unref();
62+
return child;
63+
}

0 commit comments

Comments
 (0)