fix(cli): figer le PATH d'installation dans l'unit systemd / plist launchd

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.
This commit is contained in:
2026-06-24 10:15:29 +02:00
parent 7d618c30d5
commit 2506dfb1f3
2 changed files with 47 additions and 4 deletions

View File

@@ -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) => ` <string>${xmlEscape(a)}</string>`).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 <key>PATH</key>\n <string>${xmlEscape(input.pathEnv)}</string>`
: '';
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -175,7 +186,7 @@ ${args}
<key>EnvironmentVariables</key>
<dict>
<key>NODE_ENV</key>
<string>production</string>
<string>production</string>${pathEntry}
</dict>
</dict>
</plist>
@@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise<void> {
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<void> {
programArguments,
stdoutPath: logs.out,
stderrPath: logs.err,
pathEnv,
});
const plistPath = launchAgentPlistPath(flags.label);
const uid = process.getuid?.() ?? 0;

View File

@@ -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&amp;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('<key>PATH</key>');
expect(plist).toContain('<string>/Users/me/.local/bin:/usr/bin</string>');
// Reste dans le dict EnvironmentVariables, juste après NODE_ENV.
expect(plist.indexOf('<key>PATH</key>')).toBeGreaterThan(plist.indexOf('<key>NODE_ENV</key>'));
});
it("n'ajoute aucune clé PATH sans pathEnv (rétrocompat)", () => {
const plist = renderLaunchAgentPlist(base);
expect(plist).not.toContain('<key>PATH</key>');
});
});
describe('cli install — xmlEscape', () => {