Skip to content

Commit 43cc242

Browse files
fix(pi): pin npmCommand to stable mise node spec to prevent package root drift (#432)
When mise is detected and no npmCommand is configured in settings.json, engram setup pi now writes ["mise", "exec", "node@X.Y.Z", "--", "npm"] using the currently active Node version. Existing npmCommand values are never overwritten, and no change is made when mise is absent. Closes #385
1 parent 81e8728 commit 43cc242

2 files changed

Lines changed: 304 additions & 2 deletions

File tree

internal/setup/setup.go

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ var (
5353
injectCodexMemoryConfigFn = injectCodexMemoryConfig
5454
addClaudeCodeAllowlistFn = AddClaudeCodeAllowlist
5555
writeClaudeCodeUserMCPFn = writeClaudeCodeUserMCP
56+
57+
// resolveMiseNodeVersionFn resolves the active Node version managed by mise.
58+
// It runs "mise current node" and returns the result as a "node@X.Y.Z" specifier.
59+
// Returns an empty string when the version cannot be determined.
60+
resolveMiseNodeVersionFn = resolveMiseNodeVersion
5661
)
5762

5863
//go:embed plugins/opencode/*
@@ -302,14 +307,26 @@ func installPi() (*Result, error) {
302307
}
303308

304309
agentDir := piAgentDir()
310+
settingsPath := filepath.Join(agentDir, "settings.json")
305311
files := 0
306-
settingsChanged, err := ensurePiPackageSettings(filepath.Join(agentDir, "settings.json"))
312+
313+
// ensurePiNpmCommand must run before ensurePiPackageSettings so that a single
314+
// write covers both npm command pinning and package list updates when both are
315+
// needed on a fresh install. If npmCommand was already set we still proceed and
316+
// let ensurePiPackageSettings handle the packages field independently.
317+
npmChanged, err := ensurePiNpmCommand(settingsPath)
318+
if err != nil {
319+
return nil, err
320+
}
321+
322+
settingsChanged, err := ensurePiPackageSettings(settingsPath)
307323
if err != nil {
308324
return nil, err
309325
}
310-
if settingsChanged {
326+
if npmChanged || settingsChanged {
311327
files++
312328
}
329+
313330
mcpChanged, err := ensurePiMCPConfig(filepath.Join(agentDir, "mcp.json"))
314331
if err != nil {
315332
return nil, err
@@ -351,6 +368,60 @@ func ensurePiPackageSettings(settingsPath string) (bool, error) {
351368
return true, writeJSONConfig(settingsPath, config)
352369
}
353370

371+
// ensurePiNpmCommand pins the npm command in Pi's settings.json when mise is
372+
// detected. This prevents Node version drift from silently changing which npm
373+
// root Pi uses for package lookups and installs.
374+
//
375+
// Behavior:
376+
// - If mise is not found in PATH: no-op (returns false, nil).
377+
// - If npmCommand already exists in settings.json: no-op (returns false, nil).
378+
// - Otherwise: writes npmCommand as ["mise", "exec", "<node-spec>", "--", "npm"].
379+
//
380+
// The node spec is resolved via "mise current node". If resolution fails,
381+
// the bare "node" tool name is used so mise still picks the active version.
382+
func ensurePiNpmCommand(settingsPath string) (bool, error) {
383+
if _, err := lookPathFn("mise"); err != nil {
384+
return false, nil // mise not present — nothing to pin
385+
}
386+
387+
config, err := readJSONConfig(settingsPath)
388+
if err != nil {
389+
return false, fmt.Errorf("read Pi settings for npmCommand: %w", err)
390+
}
391+
392+
if _, exists := config["npmCommand"]; exists {
393+
return false, nil // user already configured npmCommand — preserve it
394+
}
395+
396+
nodeSpec := resolveMiseNodeVersionFn()
397+
if nodeSpec == "" {
398+
nodeSpec = "node" // fallback: let mise pick the active version at runtime
399+
}
400+
401+
npmCmd := []string{"mise", "exec", nodeSpec, "--", "npm"}
402+
raw, err := jsonMarshalFn(npmCmd)
403+
if err != nil {
404+
return false, fmt.Errorf("marshal Pi npmCommand: %w", err)
405+
}
406+
config["npmCommand"] = raw
407+
return true, writeJSONConfig(settingsPath, config)
408+
}
409+
410+
// resolveMiseNodeVersion returns the active Node version managed by mise as a
411+
// versioned spec string (e.g. "node@22.12.0"). Returns an empty string when
412+
// the version cannot be determined.
413+
func resolveMiseNodeVersion() string {
414+
out, err := runCommand("mise", "current", "node")
415+
if err != nil {
416+
return ""
417+
}
418+
version := strings.TrimSpace(string(out))
419+
if version == "" {
420+
return ""
421+
}
422+
return "node@" + version
423+
}
424+
354425
func ensurePiMCPConfig(mcpPath string) (bool, error) {
355426
config, err := readJSONConfig(mcpPath)
356427
if err != nil {

internal/setup/setup_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func resetSetupSeams(t *testing.T) {
3434
oldAddClaudeCodeAllowlistFn := addClaudeCodeAllowlistFn
3535
oldOsExecutable := osExecutable
3636
oldWriteClaudeCodeUserMCPFn := writeClaudeCodeUserMCPFn
37+
oldResolveMiseNodeVersionFn := resolveMiseNodeVersionFn
3738

3839
t.Cleanup(func() {
3940
runtimeGOOS = oldRuntimeGOOS
@@ -57,6 +58,7 @@ func resetSetupSeams(t *testing.T) {
5758
addClaudeCodeAllowlistFn = oldAddClaudeCodeAllowlistFn
5859
osExecutable = oldOsExecutable
5960
writeClaudeCodeUserMCPFn = oldWriteClaudeCodeUserMCPFn
61+
resolveMiseNodeVersionFn = oldResolveMiseNodeVersionFn
6062
})
6163
}
6264

@@ -414,6 +416,235 @@ func TestInstallPiCommandFailure(t *testing.T) {
414416
}
415417
}
416418

419+
// TestEnsurePiNpmCommandWritesMiseCommand verifies that when mise is detected
420+
// and no npmCommand exists in settings.json, a stable mise-pinned command is written.
421+
func TestEnsurePiNpmCommandWritesMiseCommand(t *testing.T) {
422+
resetSetupSeams(t)
423+
agentDir := t.TempDir()
424+
settingsPath := filepath.Join(agentDir, "settings.json")
425+
426+
// mise is found in PATH
427+
lookPathFn = func(file string) (string, error) {
428+
if file == "mise" {
429+
return "/usr/local/bin/mise", nil
430+
}
431+
return "", errors.New("not found")
432+
}
433+
// mise current node returns a version
434+
resolveMiseNodeVersionFn = func() string { return "node@22.12.0" }
435+
436+
changed, err := ensurePiNpmCommand(settingsPath)
437+
if err != nil {
438+
t.Fatalf("ensurePiNpmCommand failed: %v", err)
439+
}
440+
if !changed {
441+
t.Fatalf("expected changed=true when mise detected and no npmCommand set")
442+
}
443+
444+
raw, err := os.ReadFile(settingsPath)
445+
if err != nil {
446+
t.Fatalf("read settings: %v", err)
447+
}
448+
var settings map[string]json.RawMessage
449+
if err := json.Unmarshal(raw, &settings); err != nil {
450+
t.Fatalf("parse settings: %v", err)
451+
}
452+
npmCmdRaw, ok := settings["npmCommand"]
453+
if !ok {
454+
t.Fatalf("expected npmCommand in settings, got %s", raw)
455+
}
456+
var npmCmd []string
457+
if err := json.Unmarshal(npmCmdRaw, &npmCmd); err != nil {
458+
t.Fatalf("parse npmCommand: %v", err)
459+
}
460+
want := []string{"mise", "exec", "node@22.12.0", "--", "npm"}
461+
if !reflect.DeepEqual(npmCmd, want) {
462+
t.Fatalf("expected npmCommand %v, got %v", want, npmCmd)
463+
}
464+
}
465+
466+
// TestEnsurePiNpmCommandPreservesExisting verifies that an existing npmCommand
467+
// in settings.json is never overwritten (idempotent / user-override safe).
468+
func TestEnsurePiNpmCommandPreservesExisting(t *testing.T) {
469+
resetSetupSeams(t)
470+
agentDir := t.TempDir()
471+
settingsPath := filepath.Join(agentDir, "settings.json")
472+
473+
existing := `{"npmCommand":["mise","exec","node@20.0.0","--","npm"]}`
474+
if err := os.WriteFile(settingsPath, []byte(existing), 0644); err != nil {
475+
t.Fatalf("write settings: %v", err)
476+
}
477+
478+
// mise is found — but should NOT overwrite the user's existing command
479+
lookPathFn = func(file string) (string, error) {
480+
if file == "mise" {
481+
return "/usr/local/bin/mise", nil
482+
}
483+
return "", errors.New("not found")
484+
}
485+
resolveMiseNodeVersionFn = func() string { return "node@25.9.0" }
486+
487+
changed, err := ensurePiNpmCommand(settingsPath)
488+
if err != nil {
489+
t.Fatalf("ensurePiNpmCommand failed: %v", err)
490+
}
491+
if changed {
492+
t.Fatalf("expected changed=false when npmCommand already set")
493+
}
494+
495+
raw, err := os.ReadFile(settingsPath)
496+
if err != nil {
497+
t.Fatalf("read settings: %v", err)
498+
}
499+
if string(raw) != existing {
500+
t.Fatalf("expected settings to be unchanged, got %s", raw)
501+
}
502+
}
503+
504+
// TestEnsurePiNpmCommandNoMise verifies that when mise is not found,
505+
// no npmCommand is written.
506+
func TestEnsurePiNpmCommandNoMise(t *testing.T) {
507+
resetSetupSeams(t)
508+
agentDir := t.TempDir()
509+
settingsPath := filepath.Join(agentDir, "settings.json")
510+
511+
// mise not found
512+
lookPathFn = func(file string) (string, error) {
513+
return "", errors.New("not found")
514+
}
515+
516+
changed, err := ensurePiNpmCommand(settingsPath)
517+
if err != nil {
518+
t.Fatalf("ensurePiNpmCommand failed: %v", err)
519+
}
520+
if changed {
521+
t.Fatalf("expected changed=false when mise is not detected")
522+
}
523+
524+
// settings.json should not have been created
525+
if _, err := os.Stat(settingsPath); err == nil {
526+
t.Fatalf("expected settings.json not to be created when mise is absent")
527+
}
528+
}
529+
530+
// TestEnsurePiNpmCommandMiseVersionFallback verifies that when mise is found
531+
// but the version cannot be resolved, "node" is used as the spec fallback.
532+
func TestEnsurePiNpmCommandMiseVersionFallback(t *testing.T) {
533+
resetSetupSeams(t)
534+
agentDir := t.TempDir()
535+
settingsPath := filepath.Join(agentDir, "settings.json")
536+
537+
lookPathFn = func(file string) (string, error) {
538+
if file == "mise" {
539+
return "/usr/local/bin/mise", nil
540+
}
541+
return "", errors.New("not found")
542+
}
543+
// version resolution fails — returns empty string
544+
resolveMiseNodeVersionFn = func() string { return "" }
545+
546+
changed, err := ensurePiNpmCommand(settingsPath)
547+
if err != nil {
548+
t.Fatalf("ensurePiNpmCommand failed: %v", err)
549+
}
550+
if !changed {
551+
t.Fatalf("expected changed=true even when version resolution fails")
552+
}
553+
554+
raw, err := os.ReadFile(settingsPath)
555+
if err != nil {
556+
t.Fatalf("read settings: %v", err)
557+
}
558+
var settings map[string]json.RawMessage
559+
if err := json.Unmarshal(raw, &settings); err != nil {
560+
t.Fatalf("parse settings: %v", err)
561+
}
562+
var npmCmd []string
563+
if err := json.Unmarshal(settings["npmCommand"], &npmCmd); err != nil {
564+
t.Fatalf("parse npmCommand: %v", err)
565+
}
566+
// Fallback: mise exec node -- npm (no version specifier)
567+
want := []string{"mise", "exec", "node", "--", "npm"}
568+
if !reflect.DeepEqual(npmCmd, want) {
569+
t.Fatalf("expected fallback npmCommand %v, got %v", want, npmCmd)
570+
}
571+
}
572+
573+
// TestInstallPiWritesNpmCommandWhenMiseDetected verifies that the full
574+
// installPi() flow writes npmCommand to settings.json when mise is available.
575+
func TestInstallPiWritesNpmCommandWhenMiseDetected(t *testing.T) {
576+
resetSetupSeams(t)
577+
agentDir := t.TempDir()
578+
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
579+
osExecutable = func() (string, error) { return "/opt/engram/bin/engram", nil }
580+
runCommand = func(string, ...string) ([]byte, error) { return []byte("ok"), nil }
581+
582+
lookPathFn = func(file string) (string, error) {
583+
if file == "mise" {
584+
return "/usr/local/bin/mise", nil
585+
}
586+
return "", errors.New("not found")
587+
}
588+
resolveMiseNodeVersionFn = func() string { return "node@25.9.0" }
589+
590+
result, err := Install("pi")
591+
if err != nil {
592+
t.Fatalf("Install(pi) failed: %v", err)
593+
}
594+
// settings.json changed (packages + npmCommand) + mcp.json
595+
if result.Files != 2 {
596+
t.Fatalf("expected 2 files written, got %d", result.Files)
597+
}
598+
599+
raw, err := os.ReadFile(filepath.Join(agentDir, "settings.json"))
600+
if err != nil {
601+
t.Fatalf("read settings: %v", err)
602+
}
603+
var settings map[string]json.RawMessage
604+
if err := json.Unmarshal(raw, &settings); err != nil {
605+
t.Fatalf("parse settings: %v", err)
606+
}
607+
var npmCmd []string
608+
if err := json.Unmarshal(settings["npmCommand"], &npmCmd); err != nil {
609+
t.Fatalf("parse npmCommand: %v", err)
610+
}
611+
want := []string{"mise", "exec", "node@25.9.0", "--", "npm"}
612+
if !reflect.DeepEqual(npmCmd, want) {
613+
t.Fatalf("expected npmCommand %v, got %v", want, npmCmd)
614+
}
615+
}
616+
617+
// TestInstallPiNoNpmCommandWhenNoMise verifies that installPi() does not write
618+
// npmCommand when mise is absent.
619+
func TestInstallPiNoNpmCommandWhenNoMise(t *testing.T) {
620+
resetSetupSeams(t)
621+
agentDir := t.TempDir()
622+
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
623+
osExecutable = func() (string, error) { return "/opt/engram/bin/engram", nil }
624+
runCommand = func(string, ...string) ([]byte, error) { return []byte("ok"), nil }
625+
626+
lookPathFn = func(file string) (string, error) {
627+
return "", errors.New("not found")
628+
}
629+
630+
_, err := Install("pi")
631+
if err != nil {
632+
t.Fatalf("Install(pi) failed: %v", err)
633+
}
634+
635+
raw, err := os.ReadFile(filepath.Join(agentDir, "settings.json"))
636+
if err != nil {
637+
t.Fatalf("read settings: %v", err)
638+
}
639+
var settings map[string]json.RawMessage
640+
if err := json.Unmarshal(raw, &settings); err != nil {
641+
t.Fatalf("parse settings: %v", err)
642+
}
643+
if _, ok := settings["npmCommand"]; ok {
644+
t.Fatalf("expected no npmCommand when mise is absent, got %s", raw)
645+
}
646+
}
647+
417648
func TestInstallUnknownAgent(t *testing.T) {
418649
resetSetupSeams(t)
419650
_, err := Install("unknown")

0 commit comments

Comments
 (0)