fix(cli): include ~/.local/bin in service unit PATH for user-mode installs (#994)

User-mode systemd units and launchd agents now prepend the loading user's
~/.local/bin to PATH, so user-installed agent CLIs (claude, codex, etc.,
installed via npm user prefix, pipx, fnm/nvm) are discoverable by the
kandev daemon. Without this they appeared as `not_installed` even though
they were on the user's interactive PATH.

System-mode units keep the original system-only PATH.
This commit is contained in:
Nicolas Lenepveu
2026-05-22 00:34:20 +02:00
committed by GitHub
parent b8a09bc5ea
commit 722cd31489
2 changed files with 74 additions and 16 deletions
+63 -12
View File
@@ -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(
"<key>PATH</key>\n <string>/Users/alice/.volta/tools/image/node/24.14.0/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
expect(plist).toMatch(
/<key>PATH<\/key>\s*<string>\/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(
"<key>PATH</key>\n <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
// /opt/homebrew/bin already in LAUNCHD_USER_PATH — must not be doubled.
expect(plist).toMatch(
/<key>PATH<\/key>\s*<string>[^<]+\/\.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(
/<key>PATH<\/key>\s*<string>[^<]+\/\.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(
"<key>PATH</key>\n <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
);
});
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(/<key>PATH<\/key>\s*<string>\.:/);
expect(plist).toContain(
"<key>PATH</key>\n <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>",
expect(plist).toMatch(
/<key>PATH<\/key>\s*<string>[^<]+\/\.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", () => {
+11 -4
View File
@@ -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)]);