fix(app): packaged app finds node when spawning the CLI

GUI-launched apps inherit a minimal PATH without the user's node
install, so spawning the codeburn npm shim (#!/usr/bin/env node) failed
with 'env: node: No such file or directory' in packaged builds. Spawn
now augments PATH with the resolved binary's own directory (node sits
beside the shim in nvm, Homebrew, and npm-prefix layouts) plus the
resolver's search dirs. Dev never hit this because the terminal PATH
was inherited.
This commit is contained in:
iamtoruk 2026-07-16 08:39:30 -07:00
parent 4abf5050b4
commit 700a52cd95
2 changed files with 38 additions and 5 deletions

View file

@ -2,9 +2,9 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dirname, join } from 'node:path'
import { spawnCli, spawnCliAction, killAll, CliError, nodeManagerDirs, resolveCodeburnPath } from './cli'
import { spawnCli, spawnCliAction, spawnEnvFor, killAll, CliError, nodeManagerDirs, resolveCodeburnPath } from './cli'
let dir: string
const originalBin = process.env.CODEBURN_BIN
@ -115,6 +115,24 @@ describe('spawnCli', () => {
})
})
describe('spawn PATH augmentation (GUI-launched apps have a minimal PATH)', () => {
it("prepends the resolved binary's own directory so its env-shebang finds node", async () => {
const bin = fakeBin('path-echo.js', 'process.stdout.write(JSON.stringify({ path: process.env.PATH }))')
const result = await spawnCli(['status']) as { path: string }
expect(result.path.split(':')[0]).toBe(dirname(bin))
})
it('spawnEnvFor dedupes and keeps the original PATH entries', () => {
const env = spawnEnvFor('/some/tool/bin/codeburn')
const parts = (env.PATH ?? '').split(':')
expect(parts[0]).toBe('/some/tool/bin')
expect(new Set(parts).size).toBe(parts.length)
for (const original of (process.env.PATH ?? '').split(':').filter(Boolean)) {
expect(parts).toContain(original)
}
})
})
describe('spawnCli coalescing (read-only)', () => {
it('shares one child between two concurrent identical calls', async () => {
const countFile = join(dir, 'spawns')

View file

@ -1,7 +1,7 @@
import { spawn, type ChildProcess } from 'node:child_process'
import { accessSync, constants, existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
import { homedir, platform } from 'node:os'
import { delimiter, join } from 'node:path'
import { delimiter, dirname, join } from 'node:path'
// Runs entirely in the Electron main process. This module must NOT import
// `electron` so it stays unit-testable in a plain node environment.
@ -77,6 +77,21 @@ function searchDirs(): string[] {
return [...pathDirs, ...nodeManagerDirs()]
}
/**
* Spawn env for the resolved CLI. A GUI-launched app inherits a minimal PATH
* (/usr/bin:/bin:...) that lacks the user's node install, and the `codeburn`
* npm shim starts with `#!/usr/bin/env node` so spawning it fails with
* "env: node: No such file or directory" even though the shim itself was
* found. Prepend the shim's own directory (node sits beside it in nvm,
* Homebrew, and npm-prefix layouts) plus the same dirs the resolver searches.
*/
export function spawnEnvFor(bin: string): NodeJS.ProcessEnv {
const parts = [dirname(bin), ...searchDirs(), ...(process.env.PATH || '').split(delimiter)]
const seen = new Set<string>()
const path = parts.filter(p => p && !seen.has(p) && (seen.add(p), true)).join(delimiter)
return { ...process.env, PATH: path }
}
function isExecutableFile(p: string): boolean {
try {
if (!statSync(p).isFile()) return false
@ -151,7 +166,7 @@ export function resolveCodeburnPath(): string | null {
function runCli(bin: string, args: string[], timeoutMs: number): Promise<unknown> {
return new Promise<unknown>((resolve, reject) => {
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnvFor(bin) })
activeChildren.add(child)
let stdout = ''
let stderr = ''
@ -245,7 +260,7 @@ export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}
return
}
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] })
const child = spawn(bin, args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'], env: spawnEnvFor(bin) })
activeChildren.add(child)
let stdout = ''
let stderr = ''