diff --git a/src/commands/autopilot.ts b/src/commands/autopilot.ts index 0bc81f12b..dd5598713 100644 --- a/src/commands/autopilot.ts +++ b/src/commands/autopilot.ts @@ -19,7 +19,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, appendFileSync, utimesSync, unlinkSync, chmodSync } from 'fs'; import { setCliExitVerdict } from '../core/cli-force-exit.ts'; -import { join } from 'path'; +import { join, dirname } from 'path'; import { execSync } from 'child_process'; import type { BrainEngine } from '../core/engine.ts'; import { loadPreferences } from '../core/preferences.ts'; @@ -1312,6 +1312,17 @@ function writeWrapperScript(repoPath: string): string { const gbrainPath = resolveGbrainCliPath(); const safeRepoPath = repoPath.replace(/'/g, "'\\''"); const safeGbrainPath = gbrainPath.replace(/'/g, "'\\''"); + // Bake the dir of the bun runtime actually executing this install onto PATH, + // so the wrapper finds bun wherever it lives — Homebrew (/opt/homebrew/bin), + // npm -g, Docker (/usr/local/bin), a custom BUN_INSTALL, or nix — not just + // ~/.bun/bin (which #3305 hardcoded, covering only the default bun.sh installer). + // dirname('') === '.', so guard the degenerate/empty case — otherwise a missing + // execPath would prepend '.' (cwd) onto a cron PATH. Empty prefix falls back to + // the #3305 behavior exactly. + const runtimeDir = dirname(process.execPath || ''); + const runtimePathPrefix = runtimeDir && runtimeDir !== '.' + ? `'${runtimeDir.replace(/'/g, "'\\''")}':` + : ''; const wrapper = `#!/bin/bash # Auto-generated by gbrain autopilot --install # Sources shell profile for API keys, then runs autopilot. @@ -1326,10 +1337,11 @@ source ~/.zshrc 2>/dev/null || source ~/.bashrc 2>/dev/null || true # cron/systemd/launchd — so its PATH exports never reach this subprocess. # Without bun on PATH, the exec'd gbrain (a \`#!/usr/bin/env bun\` script) fails # silently with "env: bun: No such file or directory" and leaves a stale -# lockfile that blocks every subsequent tick. Prepending ~/.bun/bin here -# keeps the wrapper self-contained regardless of which init file the OS -# loaded. -export PATH="$HOME/.bun/bin:$PATH" +# lockfile that blocks every subsequent tick. Prepending the running bun's own +# dir (derived from process.execPath at install time), with ~/.bun/bin kept as a +# fallback, keeps the wrapper self-contained regardless of where bun is installed +# or which init file the OS loaded. +export PATH=${runtimePathPrefix}"$HOME/.bun/bin:$PATH" exec '${safeGbrainPath}' autopilot --repo '${safeRepoPath}' `; writeFileSync(wrapperPath, wrapper, { mode: 0o755 }); @@ -1756,7 +1768,10 @@ function showStatus(json: boolean) { } else { try { const crontab = execSync('crontab -l 2>/dev/null || true', { encoding: 'utf-8' }); - installed = crontab.includes('gbrain autopilot'); + // The installed cron line invokes the generated wrapper (…/autopilot-run.sh); + // older installs called `gbrain autopilot` directly. Match either so status + // isn't a false negative after the wrapper indirection landed. + installed = crontab.includes('autopilot-run.sh') || crontab.includes('gbrain autopilot'); } catch { /* no crontab */ } } diff --git a/test/autopilot-install.test.ts b/test/autopilot-install.test.ts index 6b5954390..dc7fb285d 100644 --- a/test/autopilot-install.test.ts +++ b/test/autopilot-install.test.ts @@ -115,13 +115,30 @@ describe('autopilot wrapper script — bun PATH export (v0.42.x regression)', () test('wrapper exports ~/.bun/bin onto PATH before the exec', async () => { const { readFileSync } = await import('fs'); const src = readFileSync('src/commands/autopilot.ts', 'utf8'); - // The export line must appear inside the writeWrapperScript heredoc. - expect(src).toMatch(/export\s+PATH="\$HOME\/\.bun\/bin:\$PATH"/); + // The export line must appear inside the writeWrapperScript heredoc, now + // prefixed with the runtime dir derived at install time (universal), with + // ~/.bun/bin retained as a fallback. + expect(src).toMatch(/export PATH=\$\{runtimePathPrefix\}"\$HOME\/\.bun\/bin:\$PATH"/); + // The runtime dir is derived from the actually-running bun (covers Homebrew / + // npm -g / Docker / custom BUN_INSTALL / nix), not hardcoded to ~/.bun/bin. + expect(src).toMatch(/const runtimeDir = dirname\(process\.execPath/); // The export must precede the exec line, otherwise env never sees it. - const exportIdx = src.search(/export\s+PATH="\$HOME\/\.bun\/bin/); + const exportIdx = src.search(/export PATH=\$\{runtimePathPrefix\}/); const execIdx = src.search(/exec\s+'\${safeGbrainPath}'/); expect(exportIdx).toBeGreaterThan(0); expect(execIdx).toBeGreaterThan(0); expect(exportIdx).toBeLessThan(execIdx); }); }); + +// Status detection must recognize the wrapper-based cron line that --install +// actually writes (…/autopilot-run.sh), not just the legacy `gbrain autopilot` +// invocation — otherwise `--status` reports installed:false on every Linux host +// that installed via the wrapper indirection. +describe('autopilot showStatus — wrapper-path detection', () => { + test('status detects the autopilot-run.sh wrapper line', async () => { + const { readFileSync } = await import('fs'); + const src = readFileSync('src/commands/autopilot.ts', 'utf8'); + expect(src).toMatch(/crontab\.includes\('autopilot-run\.sh'\)/); + }); +});