cmd/launch: migrate Pi from detected npm prefix

This commit is contained in:
ParthSareen 2026-05-20 19:06:03 -04:00
parent ffe0773c54
commit 7665188cea
2 changed files with 376 additions and 53 deletions

View file

@ -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)

View file

@ -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)
}
})