diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts index 735b04c80f..e2d15acba2 100644 --- a/apps/cli/src/service/templates.test.ts +++ b/apps/cli/src/service/templates.test.ts @@ -43,6 +43,30 @@ describe("renderSystemdUnit", () => { expect(unit).not.toContain("KANDEV_SERVER_PORT"); }); + it("prepends %h/.local/bin to PATH for user-mode units", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/home/alice/.kandev", + logDir: "/home/alice/.kandev/logs", + mode: "user", + }); + expect(unit).toMatch( + /^Environment=PATH=%h\/\.local\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin:\/opt\/homebrew\/bin:\/home\/linuxbrew\/\.linuxbrew\/bin$/m, + ); + }); + + it("omits %h/.local/bin from PATH for system-mode units", () => { + const unit = renderSystemdUnit({ + launcher: NPM_LAUNCHER, + homeDir: "/var/lib/kandev", + logDir: "/var/lib/kandev/logs", + mode: "system", + systemUser: "alice", + }); + expect(unit).not.toContain("%h/.local/bin"); + expect(unit).toMatch(/^Environment=PATH=\/usr\/local\/bin/m); + }); + it("sets WantedBy=multi-user.target and User= for system mode", () => { const unit = renderSystemdUnit({ launcher: NPM_LAUNCHER, @@ -85,7 +109,7 @@ describe("renderSystemdUnit", () => { mode: "user", }); expect(unit).toContain( - "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -96,9 +120,9 @@ describe("renderSystemdUnit", () => { logDir: "/home/alice/.kandev/logs", mode: "user", }); - // /usr/local/bin already in SYSTEMD_PATH — dirname(nodePath) must not double it. + // /usr/local/bin already in SYSTEMD_USER_PATH — dirname(nodePath) must not double it. expect(unit).toContain( - "Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); expect(unit).not.toContain("/usr/local/bin:/usr/local/bin"); }); @@ -132,7 +156,7 @@ describe("renderSystemdUnit", () => { }); expect(unit).not.toMatch(/^Environment=PATH=\.:/m); expect(unit).toContain( - "Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -182,8 +206,8 @@ describe("renderLaunchdPlist", () => { logDir: "/Users/alice/.kandev/logs", mode: "user", }); - expect(plist).toContain( - "PATH\n /Users/alice/.volta/tools/image/node/24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", + expect(plist).toMatch( + /PATH<\/key>\s*\/Users\/alice\/\.volta\/tools\/image\/node\/24\.14\.0\/bin:[^<]+\/\.local\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, ); }); @@ -194,9 +218,22 @@ describe("renderLaunchdPlist", () => { logDir: "/Users/alice/.kandev/logs", mode: "user", }); - // /opt/homebrew/bin already first in LAUNCHD_PATH — must not be doubled. - expect(plist).toContain( - "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", + // /opt/homebrew/bin already in LAUNCHD_USER_PATH — must not be doubled. + expect(plist).toMatch( + /PATH<\/key>\s*[^<]+\/\.local\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, + ); + expect(plist).not.toContain("/opt/homebrew/bin:/opt/homebrew/bin"); + }); + + it("prepends $HOME/.local/bin to PATH for user-mode plists", () => { + const plist = renderLaunchdPlist({ + launcher: NPM_LAUNCHER, + homeDir: "/Users/alice/.kandev", + logDir: "/Users/alice/.kandev/logs", + mode: "user", + }); + expect(plist).toMatch( + /PATH<\/key>\s*[^<]+\/\.local\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, ); }); @@ -217,6 +254,20 @@ describe("renderLaunchdPlist", () => { ); }); + it("omits $HOME/.local/bin from PATH for system-mode plists", () => { + const plist = renderLaunchdPlist({ + launcher: NPM_LAUNCHER, + homeDir: "/Library/Application Support/kandev", + logDir: "/Library/Logs/kandev", + mode: "system", + systemUser: "_kandev", + }); + expect(plist).not.toContain("/.local/bin"); + expect(plist).toContain( + "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", + ); + }); + it("does not prepend '.' to plist PATH when nodePath has no POSIX separator", () => { const plist = renderLaunchdPlist({ launcher: { @@ -229,8 +280,8 @@ describe("renderLaunchdPlist", () => { mode: "user", }); expect(plist).not.toMatch(/PATH<\/key>\s*\.:/); - expect(plist).toContain( - "PATH\n /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin", + expect(plist).toMatch( + /PATH<\/key>\s*[^<]+\/\.local\/bin:\/opt\/homebrew\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin<\/string>/, ); }); @@ -271,7 +322,7 @@ describe("renderLaunchdPlist", () => { // The whole assignment must be wrapped, not just the value. expect(unit).toContain('Environment="KANDEV_HOME_DIR=/home/john doe/.kandev"'); // PATH always contains colons but no spaces — should NOT be quoted. - expect(unit).toMatch(/^Environment=PATH=\/usr\/local\/bin/m); + expect(unit).toMatch(/^Environment=PATH=%h\/\.local\/bin:\/usr\/local\/bin/m); }); it("escapes backslash + double-quote in Environment= and ExecStart values", () => { diff --git a/apps/cli/src/service/templates.ts b/apps/cli/src/service/templates.ts index e00425945b..ea536a6b32 100644 --- a/apps/cli/src/service/templates.ts +++ b/apps/cli/src/service/templates.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import path from "node:path"; import type { LauncherInfo } from "./paths"; @@ -18,9 +19,13 @@ export type UnitInputs = { mode: "user" | "system"; }; -const SYSTEMD_PATH = +// User-mode PATH includes ~/.local/bin so user-installed agent CLIs (npm user +// prefix, pipx, fnm, etc.) are discoverable. +const SYSTEMD_SYSTEM_PATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin"; -const LAUNCHD_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; +const SYSTEMD_USER_PATH = `%h/.local/bin:${SYSTEMD_SYSTEM_PATH}`; +const LAUNCHD_SYSTEM_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; +const launchdUserPath = (): string => `${os.homedir()}/.local/bin:${LAUNCHD_SYSTEM_PATH}`; // Prepend the launcher node's bin dir so `npm`/`npx` resolve under per-user // node managers (fnm, nvm, asdf, volta, mise), where node lives in a versioned @@ -64,10 +69,11 @@ export function looksLikeManagedUnit(content: string): boolean { * `npm i -g` installs don't get spurious env vars. */ export function renderSystemdUnit(input: UnitInputs): string { + const basePath = input.mode === "system" ? SYSTEMD_SYSTEM_PATH : SYSTEMD_USER_PATH; const env: string[] = [ envLine("KANDEV_HOME_DIR", input.homeDir), envLine("KANDEV_LOG_LEVEL", "info"), - envLine("PATH", pathWithNodeBinDir(SYSTEMD_PATH, input.launcher.nodePath)), + envLine("PATH", pathWithNodeBinDir(basePath, input.launcher.nodePath)), ]; if (input.port !== undefined) { env.push(envLine("KANDEV_SERVER_PORT", String(input.port))); @@ -113,10 +119,11 @@ WantedBy=${wantedBy} * get a LaunchDaemon that runs at boot regardless of login. */ export function renderLaunchdPlist(input: UnitInputs): string { + const basePath = input.mode === "system" ? LAUNCHD_SYSTEM_PATH : launchdUserPath(); const envEntries: Array<[string, string]> = [ ["KANDEV_HOME_DIR", input.homeDir], ["KANDEV_LOG_LEVEL", "info"], - ["PATH", pathWithNodeBinDir(LAUNCHD_PATH, input.launcher.nodePath)], + ["PATH", pathWithNodeBinDir(basePath, input.launcher.nodePath)], ]; if (input.port !== undefined) { envEntries.push(["KANDEV_SERVER_PORT", String(input.port)]);