@@ -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+
417648func TestInstallUnknownAgent (t * testing.T ) {
418649 resetSetupSeams (t )
419650 _ , err := Install ("unknown" )
0 commit comments