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:
@@ -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", () => {
|
||||
|
||||
@@ -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)]);
|
||||
|
||||
Reference in New Issue
Block a user