diff --git a/apps/cli/src/service/templates.test.ts b/apps/cli/src/service/templates.test.ts index 302fe76656..69725a01f9 100644 --- a/apps/cli/src/service/templates.test.ts +++ b/apps/cli/src/service/templates.test.ts @@ -64,7 +64,7 @@ describe("renderSystemdUnit", () => { mode: "user", }); expect(unit).toMatch( - /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin:\/opt\/homebrew\/bin:\/home\/linuxbrew\/\.linuxbrew\/bin$/m, + /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/bin:\/usr\/local\/bin:\/usr\/bin:\/bin:\/opt\/homebrew\/bin:\/home\/linuxbrew\/\.linuxbrew\/bin$/m, ); }); @@ -78,7 +78,7 @@ describe("renderSystemdUnit", () => { expect(unit).toContain("%h/.bun/bin"); }); - it("omits %h/.local/bin from PATH for system-mode units", () => { + it("includes run-as user agent bin dirs in PATH for system-mode units", () => { const unit = renderSystemdUnit({ launcher: NPM_LAUNCHER, homeDir: "/var/lib/kandev", @@ -86,8 +86,9 @@ describe("renderSystemdUnit", () => { mode: "system", systemUser: "alice", }); - expect(unit).not.toContain("%h/.local/bin"); - expect(unit).toMatch(/^Environment=PATH=\/usr\/local\/bin/m); + expect(unit).toMatch( + /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/bin:\/usr\/local\/bin/m, + ); }); it("sets WantedBy=multi-user.target and User= for system mode", () => { @@ -149,7 +150,7 @@ describe("renderSystemdUnit", () => { mode: "user", }); expect(unit).toContain( - "Environment=PATH=/home/alice/.local/share/fnm/node-versions/v24.14.0/installation/bin:%h/.local/bin:%h/.bun/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:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -160,9 +161,9 @@ describe("renderSystemdUnit", () => { logDir: "/home/alice/.kandev/logs", mode: "user", }); - // /usr/local/bin already in SYSTEMD_USER_PATH — dirname(nodePath) must not double it. + // /usr/local/bin already in the base service PATH — dirname(nodePath) must not double it. expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); expect(unit).not.toContain("/usr/local/bin:/usr/local/bin"); }); @@ -176,7 +177,7 @@ describe("renderSystemdUnit", () => { systemUser: "alice", }); 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:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -196,7 +197,7 @@ describe("renderSystemdUnit", () => { }); expect(unit).not.toMatch(/^Environment=PATH=\.:/m); expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -246,7 +247,7 @@ describe("renderSystemdUnit", () => { // bin. The shim's own bin dir (/home/linuxbrew/.linuxbrew/bin) is already in // the base path, so it is not duplicated. expect(unit).toContain( - "Environment=PATH=%h/.local/bin:%h/.bun/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", + "Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin", ); }); @@ -276,7 +277,9 @@ describe("renderSystemdUnit", () => { }); // Custom prefix's bin dir is prepended so npm/npx resolve, even though it // isn't one of the hardcoded default prefixes. - expect(unit).toContain("Environment=PATH=/home/me/.brew/bin:%h/.local/bin:%h/.bun/bin:"); + expect(unit).toContain( + "Environment=PATH=/home/me/.brew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:", + ); expect(unit).not.toContain("/Cellar/"); }); }); @@ -456,7 +459,9 @@ 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=%h\/\.local\/bin:%h\/\.bun\/bin:\/usr\/local\/bin/m); + expect(unit).toMatch( + /^Environment=PATH=%h\/\.local\/bin:%h\/\.bun\/bin:%h\/\.opencode\/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 2c2d62074e..48d7ebd309 100644 --- a/apps/cli/src/service/templates.ts +++ b/apps/cli/src/service/templates.ts @@ -21,12 +21,14 @@ export type UnitInputs = { serviceMetadataPath?: string; }; -// User-mode PATH includes ~/.local/bin and ~/.bun/bin so user-installed agent -// CLIs (npm user prefix, pipx, fnm, Bun globals like oh-my-pi/omp, etc.) are -// discoverable. +// Service PATH includes common per-user agent install dirs so user-installed +// CLIs (npm user prefix, pipx, fnm, Bun globals like oh-my-pi/omp, OpenCode's +// installer, etc.) are discoverable. System-mode units still run as the +// configured User=, so %h resolves to that user's home directory. const SYSTEMD_SYSTEM_PATH = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin"; -const SYSTEMD_USER_PATH = `%h/.local/bin:%h/.bun/bin:${SYSTEMD_SYSTEM_PATH}`; +const SYSTEMD_AGENT_BIN_PATH = `%h/.local/bin:%h/.bun/bin:%h/.opencode/bin`; +const SYSTEMD_SERVICE_PATH = `${SYSTEMD_AGENT_BIN_PATH}:${SYSTEMD_SYSTEM_PATH}`; const LAUNCHD_SYSTEM_PATH = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"; const launchdUserPath = (): string => `${os.homedir()}/.local/bin:${os.homedir()}/.bun/bin:${LAUNCHD_SYSTEM_PATH}`; @@ -79,7 +81,7 @@ export function looksLikeManagedUnit(content: string): boolean { */ export function renderSystemdUnit(input: UnitInputs): string { const shimPath = input.launcher.shimPath; - const basePath = input.mode === "system" ? SYSTEMD_SYSTEM_PATH : SYSTEMD_USER_PATH; + const basePath = SYSTEMD_SERVICE_PATH; // For the shim, prepend its own bin dir (the Homebrew prefix's `bin`, where // node/npm/npx live) so npx-based agents resolve even when the prefix isn't // one of the hardcoded defaults. pathWithNodeBinDir dedupes when it already is. diff --git a/docs/run-as-a-service.md b/docs/run-as-a-service.md index 0f0fd0ee7b..698a6f3821 100644 --- a/docs/run-as-a-service.md +++ b/docs/run-as-a-service.md @@ -113,7 +113,7 @@ Type=simple ExecStart=/usr/local/bin/node /usr/local/lib/node_modules/kandev/bin/cli.js --headless Environment=KANDEV_HOME_DIR=/home/alice/.kandev Environment=KANDEV_LOG_LEVEL=info -Environment=PATH=/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin +Environment=PATH=%h/.local/bin:%h/.bun/bin:%h/.opencode/bin:/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin Environment=KANDEV_RUNNING_AS_SERVICE=true Environment=KANDEV_SERVICE_MODE=user Environment=KANDEV_SERVICE_MANAGER=systemd