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;
|
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(' ');
|
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).
|
// KillSignal=SIGTERM + TimeoutStopSec=10 collent au drain de runDaemon (SIGTERM → drain 1s → close).
|
||||||
return `[Unit]
|
return `[Unit]
|
||||||
Description=Arboretum — git worktree & Claude Code dashboard
|
Description=Arboretum — git worktree & Claude Code dashboard
|
||||||
@@ -136,7 +141,7 @@ Restart=on-failure
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=10
|
TimeoutStopSec=10
|
||||||
Environment=NODE_ENV=production
|
Environment=NODE_ENV=production${pathLine}
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
@@ -148,8 +153,14 @@ export function renderLaunchAgentPlist(input: {
|
|||||||
programArguments: string[];
|
programArguments: string[];
|
||||||
stdoutPath: string;
|
stdoutPath: string;
|
||||||
stderrPath: string;
|
stderrPath: string;
|
||||||
|
pathEnv?: string | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
const args = input.programArguments.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
|
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).
|
// KeepAlive/SuccessfulExit=false ≈ Restart=on-failure (ne relance pas après un drain volontaire).
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
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">
|
<!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>
|
<key>EnvironmentVariables</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>NODE_ENV</key>
|
<key>NODE_ENV</key>
|
||||||
<string>production</string>
|
<string>production</string>${pathEntry}
|
||||||
</dict>
|
</dict>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -258,9 +269,12 @@ export async function runInstall(argv: string[]): Promise<void> {
|
|||||||
const serviceArgs = buildServiceArgs(flags);
|
const serviceArgs = buildServiceArgs(flags);
|
||||||
const { exec, args: binArgs } = resolveBin(flags);
|
const { exec, args: binArgs } = resolveBin(flags);
|
||||||
const scriptArgs = [...binArgs, ...serviceArgs];
|
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') {
|
if (platform === 'linux') {
|
||||||
const unit = renderSystemdUnit({ exec, scriptArgs });
|
const unit = renderSystemdUnit({ exec, scriptArgs, pathEnv });
|
||||||
const unitPath = systemdUnitPath();
|
const unitPath = systemdUnitPath();
|
||||||
if (flags.dryRun) {
|
if (flags.dryRun) {
|
||||||
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
console.log(`# ${unitPath}\n${unit}\n# commands:`);
|
||||||
@@ -295,6 +309,7 @@ export async function runInstall(argv: string[]): Promise<void> {
|
|||||||
programArguments,
|
programArguments,
|
||||||
stdoutPath: logs.out,
|
stdoutPath: logs.out,
|
||||||
stderrPath: logs.err,
|
stderrPath: logs.err,
|
||||||
|
pathEnv,
|
||||||
});
|
});
|
||||||
const plistPath = launchAgentPlistPath(flags.label);
|
const plistPath = launchAgentPlistPath(flags.label);
|
||||||
const uid = process.getuid?.() ?? 0;
|
const uid = process.getuid?.() ?? 0;
|
||||||
|
|||||||
@@ -79,6 +79,21 @@ describe('cli install — renderSystemdUnit', () => {
|
|||||||
expect(unit).toContain('ExecStart="/path with space/node" /s.js');
|
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', () => {
|
it('snapshot du unit pour un jeu de flags fixe', () => {
|
||||||
const unit = renderSystemdUnit({
|
const unit = renderSystemdUnit({
|
||||||
exec: '/usr/bin/node',
|
exec: '/usr/bin/node',
|
||||||
@@ -133,6 +148,19 @@ describe('cli install — renderLaunchAgentPlist', () => {
|
|||||||
expect(plist).toContain('https://a?b&c=d');
|
expect(plist).toContain('https://a?b&c=d');
|
||||||
expect(plist).not.toContain('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', () => {
|
describe('cli install — xmlEscape', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user