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:
@@ -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;
|
||||
|
||||
@@ -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('<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', () => {
|
||||
|
||||
Reference in New Issue
Block a user