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', () => {