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)]);