From 2506dfb1f3c207ae5792b3082bcaed809ec9bdff Mon Sep 17 00:00:00 2001 From: Johan LEROY Date: Wed, 24 Jun 2026 10:15:29 +0200 Subject: [PATCH] fix(cli): figer le PATH d'installation dans l'unit systemd / plist launchd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un service systemd user / LaunchAgent démarre avec un PATH minimal qui n'inclut pas ~/.local/bin (où vit le binaire `claude`) → resolveClaudeBin (`which claude`) échouait avec « Claude Code CLI not found in PATH ». `arboretum install` fige désormais le PATH de l'environnement d'installation dans l'unit/plist. --- packages/server/src/cli/install.ts | 23 +++++++++++++++---- packages/server/test/cli-install.test.ts | 28 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/server/src/cli/install.ts b/packages/server/src/cli/install.ts index 4021098..e2f0c2d 100644 --- a/packages/server/src/cli/install.ts +++ b/packages/server/src/cli/install.ts @@ -122,8 +122,13 @@ function quoteIfNeeded(token: string): string { return /\s/.test(token) ? `"${token}"` : token; } -export function renderSystemdUnit(input: { exec: string; scriptArgs: string[] }): string { +export function renderSystemdUnit(input: { exec: string; scriptArgs: string[]; pathEnv?: string | undefined }): string { const execStart = [input.exec, ...input.scriptArgs].map(quoteIfNeeded).join(' '); + // Un service systemd user démarre avec un PATH minimal (typiquement /usr/bin:/bin…) qui n'inclut + // PAS ~/.local/bin ni le bin npm/nvm où vit le CLI `claude` → resolveClaudeBin() (`which claude`) + // échouerait. On fige donc le PATH de l'environnement d'installation, où `claude` est résolvable. + // Doubles quotes systemd : tolèrent un chemin du PATH contenant des espaces. + const pathLine = input.pathEnv ? `\nEnvironment="PATH=${input.pathEnv}"` : ''; // KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close). return `[Unit] Description=Arboretum — git worktree & Claude Code dashboard @@ -136,7 +141,7 @@ Restart=on-failure RestartSec=5 KillSignal=SIGTERM TimeoutStopSec=10 -Environment=NODE_ENV=production +Environment=NODE_ENV=production${pathLine} [Install] WantedBy=default.target @@ -148,8 +153,14 @@ export function renderLaunchAgentPlist(input: { programArguments: string[]; stdoutPath: string; stderrPath: string; + pathEnv?: string | undefined; }): string { const args = input.programArguments.map((a) => ` ${xmlEscape(a)}`).join('\n'); + // launchd démarre un LaunchAgent avec un PATH minimal qui n'inclut pas ~/.local/bin ni le bin + // npm/nvm où vit le CLI `claude` → resolveClaudeBin() échouerait. On fige le PATH d'installation. + const pathEntry = input.pathEnv + ? `\n PATH\n ${xmlEscape(input.pathEnv)}` + : ''; // KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire). return ` @@ -175,7 +186,7 @@ ${args} EnvironmentVariables NODE_ENV - production + production${pathEntry} @@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise { const serviceArgs = buildServiceArgs(flags); const { exec, args: binArgs } = resolveBin(flags); const scriptArgs = [...binArgs, ...serviceArgs]; + // PATH de l'environnement d'installation (shell interactif où `claude` est résolvable) : on le + // fige dans l'unit/plist car systemd/launchd démarrent le service avec un PATH minimal. + const pathEnv = process.env.PATH; if (platform === 'linux') { - const unit = renderSystemdUnit({ exec, scriptArgs }); + const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv }); const unitPath = systemdUnitPath(); if (flags.dryRun) { console.log(`# ${unitPath}\n${unit}\n# commands:`); @@ -295,6 +309,7 @@ export async function runInstall(argv: string[]): Promise { programArguments, stdoutPath: logs.out, stderrPath: logs.err, + pathEnv, }); const plistPath = launchAgentPlistPath(flags.label); const uid = process.getuid?.() ?? 0; diff --git a/packages/server/test/cli-install.test.ts b/packages/server/test/cli-install.test.ts index eabbbd2..7e20c1a 100644 --- a/packages/server/test/cli-install.test.ts +++ b/packages/server/test/cli-install.test.ts @@ -79,6 +79,21 @@ describe('cli install — renderSystemdUnit', () => { expect(unit).toContain('ExecStart="/path with space/node" /s.js'); }); + it('fige le PATH d\'installation quand fourni (le service systemd a un PATH minimal)', () => { + const unit = renderSystemdUnit({ + exec: '/usr/bin/node', + scriptArgs: ['/s.js'], + pathEnv: '/home/me/.local/bin:/usr/bin', + }); + // Doubles quotes systemd : un chemin du PATH peut contenir un espace. + expect(unit).toContain('Environment="PATH=/home/me/.local/bin:/usr/bin"'); + }); + + it("n'émet aucune ligne PATH sans pathEnv (rétrocompat)", () => { + const unit = renderSystemdUnit({ exec: '/usr/bin/node', scriptArgs: ['/s.js'] }); + expect(unit).not.toContain('PATH='); + }); + it('snapshot du unit pour un jeu de flags fixe', () => { const unit = renderSystemdUnit({ exec: '/usr/bin/node', @@ -133,6 +148,19 @@ describe('cli install — renderLaunchAgentPlist', () => { expect(plist).toContain('https://a?b&c=d'); expect(plist).not.toContain('b&c=d'); }); + + it('ajoute la clé PATH dans EnvironmentVariables quand fournie (launchd a un PATH minimal)', () => { + const plist = renderLaunchAgentPlist({ ...base, pathEnv: '/Users/me/.local/bin:/usr/bin' }); + expect(plist).toContain('PATH'); + expect(plist).toContain('/Users/me/.local/bin:/usr/bin'); + // Reste dans le dict EnvironmentVariables, juste après NODE_ENV. + expect(plist.indexOf('PATH')).toBeGreaterThan(plist.indexOf('NODE_ENV')); + }); + + it("n'ajoute aucune clé PATH sans pathEnv (rétrocompat)", () => { + const plist = renderLaunchAgentPlist(base); + expect(plist).not.toContain('PATH'); + }); }); describe('cli install — xmlEscape', () => {