fix: preserve nvm node path in service installs (#1527)
This commit is contained in:
@@ -208,14 +208,15 @@ func installSystemd(args serviceArgs, build BuildInfo, unitPath string) int {
|
||||
return 1
|
||||
}
|
||||
unit := renderSystemdUnit(nativeServiceUnitInput{
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: serviceUser(args.System),
|
||||
BundleDir: serviceBundleDir(self),
|
||||
Version: serviceVersion(build.Version),
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: serviceUser(args.System),
|
||||
BundleDir: serviceBundleDir(self),
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
})
|
||||
if err := backupUnmanagedServiceFile(unitPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
@@ -301,15 +302,16 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain
|
||||
return 1
|
||||
}
|
||||
plist := renderLaunchdPlist(nativeServiceUnitInput{
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: serviceUser(args.System),
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: serviceBundleDir(self),
|
||||
Version: serviceVersion(build.Version),
|
||||
Executable: self,
|
||||
HomeDir: homeDir,
|
||||
LogDir: logDir,
|
||||
Port: args.Port,
|
||||
System: args.System,
|
||||
SystemUser: serviceUser(args.System),
|
||||
NoBootStart: args.NoBootStart,
|
||||
BundleDir: serviceBundleDir(self),
|
||||
Version: serviceVersion(build.Version),
|
||||
PathPrefixes: serviceNodeToolBinDirs(),
|
||||
})
|
||||
if err := backupUnmanagedServiceFile(plistPath); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "[kandev] "+err.Error())
|
||||
@@ -336,22 +338,116 @@ func installLaunchd(args serviceArgs, build BuildInfo, plistPath, target, domain
|
||||
}
|
||||
|
||||
type nativeServiceUnitInput struct {
|
||||
Executable string
|
||||
HomeDir string
|
||||
LogDir string
|
||||
Port int
|
||||
System bool
|
||||
SystemUser string
|
||||
NoBootStart bool
|
||||
BundleDir string
|
||||
Version string
|
||||
Executable string
|
||||
HomeDir string
|
||||
LogDir string
|
||||
Port int
|
||||
System bool
|
||||
SystemUser string
|
||||
NoBootStart bool
|
||||
BundleDir string
|
||||
Version string
|
||||
PathPrefixes []string
|
||||
}
|
||||
|
||||
const (
|
||||
systemdServicePath = "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin"
|
||||
launchdServicePath = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
|
||||
)
|
||||
|
||||
func serviceNodeToolBinDirs() []string {
|
||||
var dirs []string
|
||||
addPath := func(value string) {
|
||||
dir, ok := serviceNodeToolBinDir(value)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
addPath(os.Getenv("npm_node_execpath"))
|
||||
for _, name := range []string{"node", "npm", "npx"} {
|
||||
if resolved, err := exec.LookPath(name); err == nil {
|
||||
addPath(resolved)
|
||||
}
|
||||
}
|
||||
return cleanServicePathDirs(dirs)
|
||||
}
|
||||
|
||||
func serviceNodeToolBinDir(value string) (string, bool) {
|
||||
if value == "" {
|
||||
return "", false
|
||||
}
|
||||
dir := filepath.Dir(value)
|
||||
if fnmMultishellToolDir(dir) {
|
||||
target, err := filepath.EvalSymlinks(value)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
dir = filepath.Dir(target)
|
||||
}
|
||||
if transientNodeToolDir(dir) {
|
||||
return "", false
|
||||
}
|
||||
return dir, true
|
||||
}
|
||||
|
||||
func transientNodeToolDir(dir string) bool {
|
||||
clean := filepath.Clean(dir)
|
||||
separator := string(filepath.Separator)
|
||||
return strings.Contains(clean, separator+".npm"+separator+"_npx"+separator) ||
|
||||
fnmMultishellToolDir(clean) ||
|
||||
strings.Contains(clean, separator+"node_modules"+separator+".bin")
|
||||
}
|
||||
|
||||
func fnmMultishellToolDir(dir string) bool {
|
||||
separator := string(filepath.Separator)
|
||||
return strings.Contains(filepath.Clean(dir), separator+"fnm_multishells"+separator)
|
||||
}
|
||||
|
||||
func servicePathWithPrefixes(base string, prefixes []string) string {
|
||||
baseParts := strings.Split(base, ":")
|
||||
seen := make(map[string]struct{}, len(baseParts)+len(prefixes))
|
||||
for _, part := range baseParts {
|
||||
seen[part] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(prefixes)+len(baseParts))
|
||||
for _, dir := range cleanServicePathDirs(prefixes) {
|
||||
if _, ok := seen[dir]; ok {
|
||||
continue
|
||||
}
|
||||
seen[dir] = struct{}{}
|
||||
out = append(out, dir)
|
||||
}
|
||||
out = append(out, baseParts...)
|
||||
return strings.Join(out, ":")
|
||||
}
|
||||
|
||||
func cleanServicePathDirs(dirs []string) []string {
|
||||
seen := make(map[string]struct{}, len(dirs))
|
||||
out := make([]string, 0, len(dirs))
|
||||
for _, dir := range dirs {
|
||||
if dir == "" || strings.ContainsAny(dir, ":%\n\r") || strings.ContainsRune(dir, 0) {
|
||||
continue
|
||||
}
|
||||
clean := filepath.Clean(dir)
|
||||
if !filepath.IsAbs(clean) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[clean]; ok {
|
||||
continue
|
||||
}
|
||||
seen[clean] = struct{}{}
|
||||
out = append(out, clean)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderSystemdUnit(input nativeServiceUnitInput) string {
|
||||
pathValue := servicePathWithPrefixes(systemdServicePath, input.PathPrefixes)
|
||||
env := []string{
|
||||
serviceEnvLine("KANDEV_HOME_DIR", input.HomeDir),
|
||||
serviceEnvLine("KANDEV_LOG_LEVEL", "info"),
|
||||
serviceEnvLineAllowSpecifiers("PATH", "/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin:/home/linuxbrew/.linuxbrew/bin:%h/.local/bin:%h/.bun/bin:%h/.opencode/bin"),
|
||||
serviceEnvLineAllowSpecifiers("PATH", pathValue),
|
||||
}
|
||||
if input.Port != 0 {
|
||||
env = append(env, serviceEnvLine("KANDEV_SERVER_PORT", fmt.Sprint(input.Port)))
|
||||
@@ -378,7 +474,8 @@ func renderSystemdUnit(input nativeServiceUnitInput) string {
|
||||
}
|
||||
|
||||
func renderLaunchdPlist(input nativeServiceUnitInput) string {
|
||||
envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"}}
|
||||
pathValue := servicePathWithPrefixes(launchdServicePath, input.PathPrefixes)
|
||||
envEntries := [][2]string{{"KANDEV_HOME_DIR", input.HomeDir}, {"KANDEV_LOG_LEVEL", "info"}, {"PATH", pathValue}}
|
||||
if input.Port != 0 {
|
||||
envEntries = append(envEntries, [2]string{"KANDEV_SERVER_PORT", fmt.Sprint(input.Port)})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package launcher
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -164,6 +165,116 @@ func TestInstallSystemdMessagesDistinguishBootStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSystemdIncludesActiveNodeBinInPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("systemd service PATH rendering is POSIX-only")
|
||||
}
|
||||
originalExecutablePath := executablePath
|
||||
originalExecuteServiceCommand := executeServiceCommand
|
||||
originalServicePrintln := servicePrintln
|
||||
t.Cleanup(func() {
|
||||
executablePath = originalExecutablePath
|
||||
executeServiceCommand = originalExecuteServiceCommand
|
||||
servicePrintln = originalServicePrintln
|
||||
})
|
||||
|
||||
tmp := t.TempDir()
|
||||
nodeBin := filepath.Join(tmp, "home", "alice", ".nvm", "versions", "node", "v25.2.1", "bin")
|
||||
if err := os.MkdirAll(nodeBin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"node", "npm", "npx"} {
|
||||
if err := os.WriteFile(filepath.Join(nodeBin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", nodeBin+string(os.PathListSeparator)+"/usr/bin:/bin")
|
||||
t.Setenv("npm_node_execpath", filepath.Join(nodeBin, "node"))
|
||||
|
||||
executablePath = func() (string, error) {
|
||||
return filepath.Join(tmp, ".npm", "_npx", "abc", "node_modules", "@kdlbs", "runtime-linux-x64", "bin", "kandev"), nil
|
||||
}
|
||||
executeServiceCommand = func(string, ...string) error { return nil }
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
unitPath := filepath.Join(tmp, "kandev.service")
|
||||
code := installSystemd(
|
||||
serviceArgs{
|
||||
Action: actionInstall,
|
||||
HomeDir: filepath.Join(tmp, "kandev-home"),
|
||||
NoBootStart: true,
|
||||
},
|
||||
BuildInfo{Version: "test"},
|
||||
unitPath,
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("installSystemd() = %d, want 0", code)
|
||||
}
|
||||
|
||||
unit, err := os.ReadFile(unitPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPrefix := "Environment=PATH=" + nodeBin + ":"
|
||||
if !strings.Contains(string(unit), wantPrefix) {
|
||||
t.Fatalf("unit PATH does not include active node bin %q:\n%s", nodeBin, unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallLaunchdIncludesActiveNodeBinInPath(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("launchd service PATH rendering is POSIX-only")
|
||||
}
|
||||
originalExecutablePath := executablePath
|
||||
originalExecuteServiceCommand := executeServiceCommand
|
||||
originalServicePrintln := servicePrintln
|
||||
t.Cleanup(func() {
|
||||
executablePath = originalExecutablePath
|
||||
executeServiceCommand = originalExecuteServiceCommand
|
||||
servicePrintln = originalServicePrintln
|
||||
})
|
||||
|
||||
tmp := t.TempDir()
|
||||
nodeBin := filepath.Join(tmp, "Users", "alice", ".nvm", "versions", "node", "v25.2.1", "bin")
|
||||
if err := os.MkdirAll(nodeBin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"node", "npm", "npx"} {
|
||||
if err := os.WriteFile(filepath.Join(nodeBin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", nodeBin+string(os.PathListSeparator)+"/usr/bin:/bin")
|
||||
t.Setenv("npm_node_execpath", filepath.Join(nodeBin, "node"))
|
||||
|
||||
executablePath = func() (string, error) {
|
||||
return filepath.Join(tmp, ".npm", "_npx", "abc", "node_modules", "@kdlbs", "runtime-darwin-arm64", "bin", "kandev"), nil
|
||||
}
|
||||
executeServiceCommand = func(string, ...string) error { return nil }
|
||||
servicePrintln = func(string) {}
|
||||
|
||||
plistPath := filepath.Join(tmp, "com.kdlbs.kandev.plist")
|
||||
code := installLaunchd(
|
||||
serviceArgs{Action: actionInstall, HomeDir: filepath.Join(tmp, "kandev-home")},
|
||||
BuildInfo{Version: "test"},
|
||||
plistPath,
|
||||
"gui/501/com.kdlbs.kandev",
|
||||
"gui/501",
|
||||
)
|
||||
if code != 0 {
|
||||
t.Fatalf("installLaunchd() = %d, want 0", code)
|
||||
}
|
||||
|
||||
plist, err := os.ReadFile(plistPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPath := "<key>PATH</key>\n <string>" + nodeBin + ":"
|
||||
if !strings.Contains(string(plist), wantPath) {
|
||||
t.Fatalf("plist PATH does not include active node bin %q:\n%s", nodeBin, plist)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallLaunchdMessagesDistinguishBootStart(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -289,6 +400,86 @@ func TestServiceEnvLineAllowSpecifiersPreservesSystemdSpecifiers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePathWithPrefixesPrependsValidUniqueDirs(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("service PATH rendering is POSIX-only")
|
||||
}
|
||||
nodeBin := "/home/alice/.nvm/versions/node/v25.2.1/bin"
|
||||
got := servicePathWithPrefixes(
|
||||
"/usr/bin:/bin:%h/.local/bin",
|
||||
[]string{nodeBin, "/usr/bin", "relative/bin", "/bad:dir", "/bad%dir", "/bad\nline", "/bad\rline", nodeBin},
|
||||
)
|
||||
want := nodeBin + ":/usr/bin:/bin:%h/.local/bin"
|
||||
if got != want {
|
||||
t.Fatalf("servicePathWithPrefixes() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNodeToolBinDirsSkipsTransientNpxSandbox(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("service PATH rendering is POSIX-only")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
nodeBin := filepath.Join(tmp, "home", "alice", ".nvm", "versions", "node", "v25.2.1", "bin")
|
||||
npxBin := filepath.Join(tmp, "home", "alice", ".npm", "_npx", "abc", "node_modules", ".bin")
|
||||
for _, dir := range []string{nodeBin, npxBin} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"node", "npm"} {
|
||||
if err := os.WriteFile(filepath.Join(nodeBin, name), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(npxBin, "npx"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", npxBin+string(os.PathListSeparator)+nodeBin)
|
||||
t.Setenv("npm_node_execpath", filepath.Join(nodeBin, "node"))
|
||||
|
||||
got := serviceNodeToolBinDirs()
|
||||
if !slices.Contains(got, nodeBin) {
|
||||
t.Fatalf("serviceNodeToolBinDirs() = %v, want stable node bin %q", got, nodeBin)
|
||||
}
|
||||
if slices.Contains(got, npxBin) {
|
||||
t.Fatalf("serviceNodeToolBinDirs() included transient npx bin %q: %v", npxBin, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceNodeToolBinDirsResolvesFnmMultishellSymlinks(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("service PATH rendering is POSIX-only")
|
||||
}
|
||||
tmp := t.TempDir()
|
||||
stableBin := filepath.Join(tmp, "home", "alice", ".local", "share", "fnm", "node-versions", "v25.2.1", "installation", "bin")
|
||||
multishellBin := filepath.Join(tmp, "run", "user", "1000", "fnm_multishells", "12345_67890", "bin")
|
||||
for _, dir := range []string{stableBin, multishellBin} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"node", "npm", "npx"} {
|
||||
stablePath := filepath.Join(stableBin, name)
|
||||
if err := os.WriteFile(stablePath, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(stablePath, filepath.Join(multishellBin, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("PATH", multishellBin)
|
||||
t.Setenv("npm_node_execpath", filepath.Join(multishellBin, "node"))
|
||||
|
||||
got := serviceNodeToolBinDirs()
|
||||
if !slices.Contains(got, stableBin) {
|
||||
t.Fatalf("serviceNodeToolBinDirs() = %v, want stable fnm node bin %q", got, stableBin)
|
||||
}
|
||||
if slices.Contains(got, multishellBin) {
|
||||
t.Fatalf("serviceNodeToolBinDirs() included transient fnm multishell bin %q: %v", multishellBin, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupUnmanagedServiceFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "kandev.service")
|
||||
original := "custom unit"
|
||||
|
||||
@@ -127,7 +127,9 @@ If you'd rather not deal with linger and you're already comfortable with sudo, i
|
||||
|
||||
## What's Inside the Unit File
|
||||
|
||||
The unit hard-codes absolute paths so it works in the empty `PATH` that systemd/launchd give a fresh service. A typical Linux user unit looks like:
|
||||
The unit hard-codes absolute paths so it works in the empty `PATH` that systemd/launchd give a fresh service. When Node tooling is installed through a per-user manager such as nvm, fnm, asdf, Volta, or mise, `kandev service install` also bakes the detected `node`/`npm`/`npx` bin directory into `PATH`; re-run the install command after changing the active Node version. For fnm, the installer resolves multishell symlinks when possible so the unit records the stable Node installation path instead of the temporary multishell directory. If the service file still points at a stale fnm multishell path, re-run `kandev service install` from a shell where `node`, `npm`, and `npx` resolve through valid symlinks. For `--system` installs, some `sudo` configurations reset `PATH` with `secure_path`; run the install command from an environment where `node`, `npm`, and `npx` still resolve, or put stable symlinks in the run-as user's `~/.local/bin`.
|
||||
|
||||
A typical Linux user unit looks like:
|
||||
|
||||
```ini
|
||||
# managed by kandev — regenerated by `kandev service install`
|
||||
|
||||
@@ -22,6 +22,7 @@ Users start Kandev from the terminal through `kandev`, but the current launcher
|
||||
- npm/npx continues to expose the `kandev` command through the `kandev` npm package. The npm package may use a minimal JavaScript shim, but that shim only resolves the platform runtime package and execs its native `bin/kandev`.
|
||||
- `make start` launches through the native `apps/backend/bin/kandev start` path after building local artifacts.
|
||||
- Existing service installs continue to be managed by `kandev service ...`; newly generated service units execute the public `kandev` launcher path.
|
||||
- Service units preserve install-time Node/npm/npx bin directories when they are discoverable, so agents can still invoke npm-managed CLIs under service managers that do not source shell profiles.
|
||||
- Startup output continues to show the URL users should open, the MCP URL, database path when applicable, and log level when applicable.
|
||||
- Production `run` and `start` do not execute a Node.js web runtime; the Go backend serves the embedded Vite SPA assets.
|
||||
|
||||
@@ -140,7 +141,9 @@ Transitions:
|
||||
- **GIVEN** the backend has requested a restart through the restart adapter, **WHEN** the launcher receives the restart request, **THEN** only the `kandev __backend` child process is replaced.
|
||||
- **GIVEN** the user presses Ctrl-C while Kandev is running, **WHEN** the launcher handles the signal, **THEN** it terminates the backend child process before exiting.
|
||||
- **GIVEN** a new service install on Linux or macOS, **WHEN** the user runs `kandev service install`, **THEN** the generated service unit executes the public `kandev` launcher path.
|
||||
- **GIVEN** Node is managed by nvm/fnm/asdf/volta/mise and `node`, `npm`, or `npx` resolve from that manager at install time, **WHEN** the user runs `kandev service install`, **THEN** the generated service environment includes the detected Node tool bin directory in `PATH`.
|
||||
- **GIVEN** a user runs `npx kandev@latest --help`, **WHEN** npm has installed the platform runtime package, **THEN** the npm shim execs the runtime package's native `bin/kandev` and the user sees the same public help output.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Making `npx` itself Node-free.
|
||||
|
||||
Reference in New Issue
Block a user