From 7665188cea91f15d0473f17efbc46c2c1e6878fd Mon Sep 17 00:00:00 2001 From: ParthSareen Date: Wed, 20 May 2026 19:06:03 -0400 Subject: [PATCH] cmd/launch: migrate Pi from detected npm prefix --- cmd/launch/pi.go | 172 +++++++++++++++++++++++----- cmd/launch/pi_test.go | 257 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 376 insertions(+), 53 deletions(-) diff --git a/cmd/launch/pi.go b/cmd/launch/pi.go index 67defa4f2..c4bf69efa 100644 --- a/cmd/launch/pi.go +++ b/cmd/launch/pi.go @@ -64,27 +64,19 @@ func ensureNpmInstalled() error { func ensurePiInstalled() (string, error) { if _, err := exec.LookPath("pi"); err == nil { - pkg, pkgErr := installedPiPackage() + install, pkgErr := installedPiPackageInfo() if pkgErr != nil { fmt.Fprintf(os.Stderr, "%sCould not verify which Pi package is installed: %v%s\n", ansiYellow, pkgErr, ansiReset) fmt.Fprintf(os.Stderr, "Pi will still launch. To switch to the official package manually:\n npm uninstall -g %s\n npm install -g %s\n\n", piLegacyNpmPackage, piNpmPackage) return "pi", nil } - if pkg == piLegacyNpmPackage { - ok, err := ConfirmPrompt("Switch Pi to the official package? Your settings and extensions will be kept.") - if err != nil { - return "", err - } - if !ok { - return "", fmt.Errorf("pi migration cancelled\n\nTo migrate later, re-run:\n ollama launch pi\n\nOr migrate manually:\n npm uninstall -g %s\n npm install -g %s", piLegacyNpmPackage, piNpmPackage) - } - + if install.packageName == piLegacyNpmPackage { fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset) - if err := uninstallLegacyPiPackage(); err != nil { + if err := migrateLegacyPiPackage(install.npmPrefix); err != nil { return "", err } - if err := installPiPackage(); err != nil { + if err := requirePiOnPath(); err != nil { return "", err } } @@ -95,6 +87,28 @@ func ensurePiInstalled() (string, error) { return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi") } + install, pkgErr := installedPiPackageInfo() + if pkgErr == nil && install.packageName == piLegacyNpmPackage { + fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset) + if err := migrateLegacyPiPackage(install.npmPrefix); err != nil { + return "", err + } + if err := requirePiOnPath(); err != nil { + return "", err + } + return "pi", nil + } + if pkgErr == nil && install.packageName == piNpmPackage { + fmt.Fprintf(os.Stderr, "%sInstalling Pi...%s\n", ansiGray, ansiReset) + if err := installPiPackageWithPrefix(install.npmPrefix); err != nil { + return "", err + } + if err := requirePiOnPath(); err != nil { + return "", err + } + return "pi", nil + } + ok, err := ConfirmPrompt("Install Pi with npm?") if err != nil { return "", err @@ -108,23 +122,60 @@ func ensurePiInstalled() (string, error) { return "", err } - if _, err := exec.LookPath("pi"); err != nil { - return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + if err := requirePiOnPath(); err != nil { + return "", err } fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset) return "pi", nil } +func requirePiOnPath() error { + if _, err := exec.LookPath("pi"); err != nil { + return fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell") + } + return nil +} + func installPiPackage() error { - if err := runQuietCommand("npm", "install", "-g", piNpmPackage+"@latest"); err != nil { + return installPiPackageWithPrefix("") +} + +func installPiPackageWithPrefix(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest")...); err != nil { return fmt.Errorf("failed to install pi: %w", err) } return nil } -func uninstallLegacyPiPackage() error { - if err := runQuietCommand("npm", "uninstall", "-g", piLegacyNpmPackage); err != nil { +func migrateLegacyPiPackage(prefix string) error { + if err := installPiPackageForced(prefix); err != nil { + return err + } + + installed, err := npmPackageInstalledWithPrefix(piNpmPackage, prefix) + if err != nil { + return fmt.Errorf("failed to verify official pi package: %w", err) + } + if !installed { + return fmt.Errorf("failed to verify official pi package") + } + + if err := uninstallLegacyPiPackageWithPrefix(prefix); err != nil { + return err + } + return installPiPackageWithPrefix(prefix) +} + +func installPiPackageForced(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest", "--force")...); err != nil { + return fmt.Errorf("failed to install pi: %w", err) + } + return nil +} + +func uninstallLegacyPiPackageWithPrefix(prefix string) error { + if err := runQuietCommand("npm", npmArgs(prefix, "uninstall", "-g", piLegacyNpmPackage)...); err != nil { return fmt.Errorf("failed to remove legacy pi package: %w", err) } return nil @@ -143,32 +194,92 @@ func runQuietCommand(name string, args ...string) error { return fmt.Errorf("%w: %s", err, msg) } -func installedPiPackage() (string, error) { +type piPackageInstall struct { + packageName string + npmPrefix string +} + +func installedPiPackageInfo() (piPackageInstall, error) { if _, err := exec.LookPath("npm"); err != nil { - return "", err + return piPackageInstall{}, err } - installed, err := npmPackageInstalled(piNpmPackage) + if bin, err := exec.LookPath("pi"); err == nil { + install, err := piPackageInstallFromBinary(bin) + if err == nil && install.packageName != "" { + return install, nil + } + } + + installed, err := npmPackageInstalled(piLegacyNpmPackage) if err != nil { - return "", err + return piPackageInstall{}, err } if installed { - return piNpmPackage, nil + return piPackageInstall{packageName: piLegacyNpmPackage}, nil } - installed, err = npmPackageInstalled(piLegacyNpmPackage) + installed, err = npmPackageInstalled(piNpmPackage) if err != nil { - return "", err + return piPackageInstall{}, err } if installed { - return piLegacyNpmPackage, nil + return piPackageInstall{packageName: piNpmPackage}, nil } - return "", nil + return piPackageInstall{}, nil +} + +func piPackageInstallFromBinary(bin string) (piPackageInstall, error) { + realPath, err := filepath.EvalSymlinks(bin) + if err != nil { + realPath = bin + } + + dir := filepath.Dir(realPath) + for { + packageJSON := filepath.Join(dir, "package.json") + data, err := os.ReadFile(packageJSON) + if err == nil { + var payload struct { + Name string `json:"name"` + } + if json.Unmarshal(data, &payload) == nil && (payload.Name == piLegacyNpmPackage || payload.Name == piNpmPackage) { + return piPackageInstall{packageName: payload.Name, npmPrefix: npmPrefixForPackageRoot(dir)}, nil + } + } + + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + return piPackageInstall{}, nil +} + +func npmPrefixForPackageRoot(packageRoot string) string { + packageRoot = filepath.Clean(packageRoot) + nodeModules := string(filepath.Separator) + "node_modules" + string(filepath.Separator) + idx := strings.LastIndex(packageRoot, nodeModules) + if idx == -1 { + return "" + } + + libDir := packageRoot[:idx] + if filepath.Base(libDir) != "lib" { + return "" + } + return filepath.Dir(libDir) } func npmPackageInstalled(pkg string) (bool, error) { - cmd := exec.Command("npm", "ls", "-g", pkg, "--depth=0", "--json") + return npmPackageInstalledWithPrefix(pkg, "") +} + +func npmPackageInstalledWithPrefix(pkg, prefix string) (bool, error) { + cmd := exec.Command("npm", npmArgs(prefix, "ls", "-g", pkg, "--depth=0", "--json")...) out, err := cmd.Output() var payload struct { @@ -201,6 +312,13 @@ func npmPackageInstalled(pkg string) (bool, error) { return false, err } +func npmArgs(prefix string, args ...string) []string { + if prefix == "" { + return args + } + return append([]string{"--prefix", prefix}, args...) +} + func ensurePiWebSearchPackage(bin string) { if !shouldManagePiWebSearch() { fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset) diff --git a/cmd/launch/pi_test.go b/cmd/launch/pi_test.go index 8364ddcae..dfbf66b6c 100644 --- a/cmd/launch/pi_test.go +++ b/cmd/launch/pi_test.go @@ -93,18 +93,77 @@ exit 0 npmPath := filepath.Join(dir, "npm") npmScript := fmt.Sprintf(`#!/bin/sh echo "$@" >> %q +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + if [ "$PI_FAIL_OFFICIAL_INSTALL" = "1" ]; then + echo "install failed" >&2 + exit 1 + fi + : > %q + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then if [ "$3" = %q ]; then + if [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n' + exit 0 + fi printf '{"name":"lib"}\n' exit 1 fi if [ "$3" = %q ]; then - printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n' - exit 0 + if [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 fi fi exit 0 -`, filepath.Join(dir, "npm.log"), piNpmPackage, piLegacyNpmPackage, piLegacyNpmPackage) +`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage) + writeScript(t, npmPath, npmScript) + } + + seedBothPiPackagesNpm := func(t *testing.T, dir string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, "official-installed"), nil, 0o644); err != nil { + t.Fatal(err) + } + npmPath := filepath.Join(dir, "npm") + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + if [ "$3" = %q ]; then + if [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi + if [ "$3" = %q ]; then + if [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 + fi +fi +exit 0 +`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage) writeScript(t, npmPath, npmScript) } @@ -216,7 +275,8 @@ exit 0 p := &Pi{} if err := p.Run("ignored", nil, []string{"--version"}); err != nil { - t.Fatalf("Run() error = %v", err) + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) } npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) @@ -264,7 +324,7 @@ exit 0 } }) - t.Run("legacy pi package prompts migration and installs official package", func(t *testing.T) { + t.Run("legacy pi package migrates automatically to official package", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("PATH", tmpDir) @@ -275,16 +335,14 @@ exit 0 seedLegacyPiNpm(t, tmpDir) withConfirm(t, func(prompt string) (bool, error) { - if strings.Contains(prompt, "official package") && strings.Contains(prompt, "settings and extensions") { - return true, nil - } - t.Fatalf("unexpected prompt %q", prompt) + t.Fatalf("did not expect confirmation prompt, got %q", prompt) return false, nil }) p := &Pi{} if err := p.Run("ignored", nil, []string{"--version"}); err != nil { - t.Fatalf("Run() error = %v", err) + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) } npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) @@ -292,20 +350,26 @@ exit 0 t.Fatal(err) } gotNPM := string(npmCalls) - if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") { - t.Fatalf("expected official npm probe, got:\n%s", gotNPM) - } if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") { t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM) } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM) + } if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM) } if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") { t.Fatalf("expected official npm install call, got:\n%s", gotNPM) } - if strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") > strings.Index(gotNPM, "install -g "+piNpmPackage+"@latest\n") { - t.Fatalf("expected legacy uninstall before official install, got:\n%s", gotNPM) + if strings.Index(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") > strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected official install before legacy uninstall, got:\n%s", gotNPM) + } + if strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") > strings.LastIndex(gotNPM, "install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official repair install after legacy uninstall, got:\n%s", gotNPM) } piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log")) @@ -321,32 +385,173 @@ exit 0 } }) - t.Run("legacy pi package can decline migration", func(t *testing.T) { + t.Run("legacy pi package migrates even when official package is also installed", func(t *testing.T) { tmpDir := t.TempDir() setTestHome(t, tmpDir) t.Setenv("PATH", tmpDir) setCloudStatus(t, false) + seedPiWebSearchPackage(t, tmpDir, "1.0.0") + setNpmRegistryVersion(t, "1.0.0") + seedPiScript(t, tmpDir) + seedBothPiPackagesNpm(t, tmpDir) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + gotNPM := string(npmCalls) + if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") { + t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official npm install call, got:\n%s", gotNPM) + } + }) + + t.Run("legacy pi package outside current npm prefix migrates with binary prefix", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + setCloudStatus(t, false) + + commandDir := filepath.Join(tmpDir, "commands") + prefix := filepath.Join(tmpDir, "npm-global") + legacyRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent") + legacyDist := filepath.Join(legacyRoot, "dist") + if err := os.MkdirAll(filepath.Join(prefix, "bin"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(legacyDist, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(legacyRoot, "package.json"), []byte(`{"name":"`+piLegacyNpmPackage+`","version":"0.67.1"}`), 0o644); err != nil { + t.Fatal(err) + } + writeScript(t, filepath.Join(legacyDist, "cli.js"), fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", filepath.Join(tmpDir, "pi.log"))) + if err := os.Symlink(filepath.Join(legacyDist, "cli.js"), filepath.Join(prefix, "bin", "pi")); err != nil { + t.Fatal(err) + } + + npmScript := fmt.Sprintf(`#!/bin/sh +echo "$@" >> %q +prefix="" +if [ "$1" = "--prefix" ]; then + prefix="$2" + shift 2 +fi +if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + mkdir -p "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist" "$prefix/bin" + printf '{"name":"%s","version":"0.75.3"}\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/package.json" + printf '#!/bin/sh\necho "$@" >> %s\nexit 0\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + chmod +x "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" + ln -sf "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" "$prefix/bin/pi" + exit 0 +fi +if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then + : > %q + exit 0 +fi +if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then + if [ "$3" = %q ] && [ -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.75.3","overridden":false}}}\n' + exit 0 + fi + if [ "$3" = %q ] && [ ! -f %q ]; then + printf '{"name":"lib","dependencies":{"%s":{"version":"0.67.1","overridden":false}}}\n' + exit 0 + fi + printf '{"name":"lib"}\n' + exit 1 +fi +exit 0 +`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "official-installed"), piNpmPackage, filepath.Join(tmpDir, "pi.log"), piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piNpmPackage, filepath.Join(tmpDir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piLegacyNpmPackage) + if err := os.MkdirAll(commandDir, 0o755); err != nil { + t.Fatal(err) + } + writeScript(t, filepath.Join(commandDir, "npm"), npmScript) + t.Setenv("PATH", commandDir+string(os.PathListSeparator)+filepath.Join(prefix, "bin")) + + withConfirm(t, func(prompt string) (bool, error) { + t.Fatalf("did not expect confirmation prompt, got %q", prompt) + return false, nil + }) + + p := &Pi{} + if err := p.Run("ignored", nil, []string{"--version"}); err != nil { + npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls) + } + + npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if err != nil { + t.Fatal(err) + } + gotNPM := string(npmCalls) + resolvedPrefix, err := filepath.EvalSymlinks(prefix) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official install in pi binary prefix, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("expected legacy uninstall in pi binary prefix, got:\n%s", gotNPM) + } + if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest\n") { + t.Fatalf("expected official repair install in pi binary prefix, got:\n%s", gotNPM) + } + }) + + t.Run("legacy pi migration install failure does not remove legacy package", func(t *testing.T) { + tmpDir := t.TempDir() + setTestHome(t, tmpDir) + t.Setenv("PATH", tmpDir) + setCloudStatus(t, false) + t.Setenv("PI_FAIL_OFFICIAL_INSTALL", "1") seedPiScript(t, tmpDir) seedLegacyPiNpm(t, tmpDir) withConfirm(t, func(prompt string) (bool, error) { - if strings.Contains(prompt, "official package") && strings.Contains(prompt, "settings and extensions") { - return false, nil - } - t.Fatalf("unexpected prompt %q", prompt) + t.Fatalf("did not expect confirmation prompt, got %q", prompt) return false, nil }) p := &Pi{} err := p.Run("ignored", nil, nil) - if err == nil || !strings.Contains(err.Error(), "pi migration cancelled") { - t.Fatalf("expected migration cancellation error, got %v", err) + if err == nil || !strings.Contains(err.Error(), "failed to install pi") { + t.Fatalf("expected install failure error, got %v", err) } - if !strings.Contains(err.Error(), "npm uninstall -g "+piLegacyNpmPackage) { - t.Fatalf("expected legacy uninstall command in error, got %v", err) + + npmCalls, readErr := os.ReadFile(filepath.Join(tmpDir, "npm.log")) + if readErr != nil { + t.Fatal(readErr) } - if !strings.Contains(err.Error(), "npm install -g "+piNpmPackage) { - t.Fatalf("expected official install command in error, got %v", err) + gotNPM := string(npmCalls) + if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") { + t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM) + } + if strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") { + t.Fatalf("did not expect legacy uninstall after official install failure, got:\n%s", gotNPM) } })