diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index d0ac8a9..ade9883 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -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') diff --git a/app/electron/cli.ts b/app/electron/cli.ts index d344277..f0a5286 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -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() + 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 { return new Promise((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 = ''