diff --git a/__tests__/bundle-launcher.test.ts b/__tests__/bundle-launcher.test.ts new file mode 100644 index 000000000..04c079024 --- /dev/null +++ b/__tests__/bundle-launcher.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +const windows = process.platform === 'win32'; +const bash = windows + ? path.join(process.env.ProgramFiles || 'C:\\Program Files', 'Git/bin/bash.exe') + : 'bash'; +const recipe = fs.readFileSync(path.resolve('scripts/build-bundle.sh'), 'utf8'); +// Execute the release recipe itself, without downloading Node or rebuilding the UI. +const launcherRecipe = recipe.slice(recipe.indexOf('# 4. Vendored'), recipe.indexOf('# 5. Archive')); +const archiveRecipe = recipe.slice(recipe.indexOf('# 5. Archive')); +const roots: string[] = []; +const env = { ...process.env, CODEGRAPH_TELEMETRY: '0', DO_NOT_TRACK: '1', CODEGRAPH_NO_PROMPT_HOOK: '1' }; + +function shell(script: string, args: string[] = [], extraEnv: NodeJS.ProcessEnv = {}, input?: string) { + // A file avoids Windows command-line quoting altering the recipe's printf + // escapes or our argument fixtures before Bash even reads them. + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-shell-')); + roots.push(root); + const file = path.join(root, 'test.sh'); + const quote = (value: string) => "'" + value.replace(/'/g, "'\\''") + "'"; + fs.writeFileSync(file, `set -- ${args.map(quote).join(' ')}\n${script}`); + return spawnSync(bash, ['--noprofile', '--norc', file], { + encoding: 'utf8', timeout: 30_000, env: { ...env, ...extraEnv }, input, + }); +} + +function stage(osfam = windows ? 'win32' : process.platform) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph bundle space ')); + roots.push(root); + const bundle = path.join(root, `codegraph-${osfam}-x64`); + fs.mkdirSync(path.join(bundle, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(bundle, 'lib/dist/bin'), { recursive: true }); + const convert = windows ? '$(cygpath -u "$1")' : '$1'; + const node = windows ? '$(cygpath -u "$2")' : '$2'; + const result = shell(`set -eu\nSTAGE="${convert}"\nNODE_BIN="${node}"\nOSFAM="$3"\n${launcherRecipe}`, [bundle, process.execPath, osfam]); + expect(result.status, result.stderr || String(result.error)).toBe(0); + return { root, bundle }; +} + +function invoke(bundle: string, command: string, args: string[] = [], extraEnv: NodeJS.ProcessEnv = {}, input?: string) { + const convert = windows ? '$(cygpath -u "$1")' : '$1'; + // Exclude system Node: execution must use the runtime in the bundle. + return shell(`STAGE="${convert}"\nshift\nPATH="$STAGE/bin:/usr/bin:/bin"\nexport PATH\n${command}`, + [bundle, ...args], extraEnv, input); +} + +function probe(bundle: string) { + fs.writeFileSync(path.join(bundle, 'lib/dist/bin/codegraph.js'), ` +console.log(JSON.stringify({args: process.argv.slice(2), flags: process.execArgv, + host: process.env.CODEGRAPH_HOST_PPID, exe: process.execPath})); +process.exit(23); +`); +} + +function checkForwarding(bundle: string) { + probe(bundle); + const args = ['two words', '', 'quote"value', "single'value", '$literal', '*.ts', 'a&b']; + const result = invoke(bundle, 'codegraph "$@"', args, { CODEGRAPH_HOST_PPID: '12345' }); + expect(result.status, result.stderr).toBe(23); + const output = JSON.parse(result.stdout); + expect(output.args).toEqual(args); + expect(output.flags).toEqual(['--liftoff-only', '--disable-warning=ExperimentalWarning']); + expect(output.host).toBe('12345'); + expect(fs.realpathSync(output.exe)).toBe(fs.realpathSync(path.join(bundle, windows ? 'node.exe' : 'node'))); +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); +}); + +describe('bundled launchers (#1278)', () => { + it.runIf(!windows)('preserves POSIX arguments, exit status, runtime flags and host PID', () => { + checkForwarding(stage().bundle); + }); + + it.runIf(windows)('runs from Git Bash in a spaced path and forwards arguments, exit status, flags and host PID', () => { + checkForwarding(stage().bundle); + }); + + it.runIf(windows)('runs the real CLI and prompt hook in Git Bash, and keeps cmd and PowerShell working', () => { + const { bundle } = stage(); + fs.rmSync(path.join(bundle, 'lib/dist'), { recursive: true }); + fs.symlinkSync(path.resolve('dist'), path.join(bundle, 'lib/dist'), 'junction'); + const version = require('../package.json').version; + const result = invoke(bundle, 'codegraph --version'); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe(version); + const hook = invoke(bundle, 'codegraph prompt-hook', [], {}, '{"prompt":"test"}\n'); + expect(hook.status, hook.stderr).toBe(0); + const bin = path.join(bundle, 'bin'); + for (const [exe, args] of [ + ['cmd.exe', ['/d', '/s', '/c', 'codegraph --version']], + ['powershell.exe', ['-NoProfile', '-Command', 'codegraph --version']], + ] as const) { + const control = spawnSync(exe, [...args], { + cwd: bin, encoding: 'utf8', timeout: 30_000, + env: { + ...Object.fromEntries(Object.entries(env).filter(([key]) => key.toLowerCase() !== 'path')), + Path: `${bin};${process.env.Path || process.env.PATH}`, + }, + }); + expect(control.status, control.stderr).toBe(0); + expect(control.stdout.trim(), exe).toBe(version); + } + }); + + it.runIf(!windows).each(['x64', 'arm64'])('ships both launchers in the Windows %s archive', (arch) => { + const { root, bundle } = stage('win32'); + const target = `win32-${arch}`; + if (arch !== 'x64') fs.renameSync(bundle, path.join(root, `codegraph-${target}`)); + const result = shell(`set -eu\nWORK="$1"\nOUT="$1/release"\nOSFAM=win32\nTARGET="$2"\n${archiveRecipe}`, [root, target]); + expect(result.status, result.stderr).toBe(0); + const list = spawnSync('unzip', ['-Z1', path.join(root, 'release', `codegraph-${target}.zip`)], { encoding: 'utf8' }); + expect(list.status, list.stderr).toBe(0); + const entries = list.stdout.split('\n'); + for (const file of ['bin/codegraph', 'bin/codegraph.cmd', 'node.exe']) { + expect(entries).toContain(`codegraph-${target}/${file}`); + } + }); +}); diff --git a/scripts/build-bundle.sh b/scripts/build-bundle.sh index 1dc3c7661..0fef1c98e 100755 --- a/scripts/build-bundle.sh +++ b/scripts/build-bundle.sh @@ -17,7 +17,7 @@ # # Output: # unix: release/codegraph-.tar.gz (launcher: bin/codegraph) -# windows: release/codegraph-.zip (launcher: bin/codegraph.cmd) +# windows: release/codegraph-.zip (launchers: bin/codegraph + .cmd) set -euo pipefail TARGET="${1:?usage: build-bundle.sh [node-version]}" @@ -112,6 +112,15 @@ if [ "$OSFAM" = "win32" ]; then cp "$NODE_BIN" "$STAGE/node.exe" printf '@"%%~dp0..\\node.exe" --liftoff-only --disable-warning=ExperimentalWarning "%%~dp0..\\lib\\dist\\bin\\codegraph.js" %%*\r\n' \ > "$STAGE/bin/codegraph.cmd" + # Git Bash (including Claude Code hooks) does not resolve .cmd via PATHEXT. + cat > "$STAGE/bin/codegraph" <<'LAUNCH' +#!/bin/sh +DIR="$(cd "$(dirname "$0")/.." && pwd)" +# Preserve an inherited CODEGRAPH_HOST_PPID; do not replace it with MSYS's +# $PPID, which is not a native Windows PID usable by the orphan watchdog. +exec "$DIR/node.exe" --liftoff-only --disable-warning=ExperimentalWarning "$DIR/lib/dist/bin/codegraph.js" "$@" +LAUNCH + chmod +x "$STAGE/bin/codegraph" else cp "$NODE_BIN" "$STAGE/node" cat > "$STAGE/bin/codegraph" <<'LAUNCH'