diff --git a/internal/cli/uninstall.go b/internal/cli/uninstall.go index 431d4dd..ab1875e 100644 --- a/internal/cli/uninstall.go +++ b/internal/cli/uninstall.go @@ -1,6 +1,10 @@ package cli -import "github.com/spf13/cobra" +import ( + "github.com/php-debugger/installer/internal/installer" + "github.com/php-debugger/installer/internal/platform" + "github.com/spf13/cobra" +) // uninstallOptions holds flags specific to the uninstall command. type uninstallOptions struct { @@ -9,6 +13,8 @@ type uninstallOptions struct { // Interpreter uninstalls a debugger interpreter (optionally a specific // version given as a positional argument). Interpreter bool + // ZTS selects the thread-safe variant when a version is given. + ZTS bool } func newUninstallCmd() *cobra.Command { @@ -22,13 +28,23 @@ func newUninstallCmd() *cobra.Command { "it is restored.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return errNotImplemented("uninstall") + var version string + if len(args) == 1 { + version = args[0] + } + return installer.Uninstall(cmd.Context(), installer.Options{ + Scope: platform.ScopeFromUserFlag(globalOpts.User), + AssumeYes: globalOpts.Yes, + Out: cmd.OutOrStdout(), + In: cmd.InOrStdin(), + }, opts.Interpreter, opts.Extension, version, opts.ZTS) }, } f := cmd.Flags() f.BoolVarP(&opts.Extension, "extension", "e", false, "uninstall the debugger extension") f.BoolVarP(&opts.Interpreter, "interpreter", "i", false, "uninstall the debugger interpreter") + f.BoolVarP(&opts.ZTS, "zts", "z", false, "select the thread-safe (ZTS) variant of the given version") return cmd } diff --git a/internal/installer/uninstall.go b/internal/installer/uninstall.go new file mode 100644 index 0000000..44ae764 --- /dev/null +++ b/internal/installer/uninstall.go @@ -0,0 +1,214 @@ +package installer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// Uninstall removes an installed interpreter variant or the extension. For an +// interpreter, version selects a specific variant; empty means the active one. +// If removing the active interpreter leaves other variants, one is activated; +// otherwise a backed-up original interpreter is restored (if present). +func Uninstall(ctx context.Context, opts Options, wantInterp, wantExt bool, version string, zts bool) error { + env, err := opts.env() + if err != nil { + return err + } + layout, err := platform.Resolve(env, opts.Scope) + if err != nil { + return err + } + m, err := manifest.Load(layout.ManifestPath()) + if err != nil { + return err + } + + hasInterp := len(m.InterpreterKeys()) > 0 + hasExt := m.Extension != nil + + if !wantInterp && !wantExt { + switch { + case version != "": + wantInterp = true + case hasInterp && hasExt: + return errors.New("both an interpreter and an extension are installed; " + + "specify --interpreter or --extension") + case hasInterp: + wantInterp = true + case hasExt: + wantExt = true + default: + return errors.New("nothing installed to uninstall") + } + } + + if wantExt { + return uninstallExtension(opts, layout, m) + } + return uninstallInterpreter(opts, layout, m, env, version, zts) +} + +func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Manifest, env platform.Env, version string, zts bool) error { + key := version + if key != "" { + key = platform.VersionDirName(version, zts) + } else { + key = m.Active() + if key == "" { + return errors.New("no active interpreter to uninstall; specify a version") + } + } + + it, ok := m.Interpreter(key) + if !ok { + return fmt.Errorf("interpreter %q is not installed", key) + } + wasActive := m.Active() == key + + // Remove the copied config files, then the versioned directory. + for _, f := range it.ConfigFiles { + if err := removeIfExists(f); err != nil { + return fmt.Errorf("removing config %s: %w", f, err) + } + } + if err := os.RemoveAll(it.Dir); err != nil { + return fmt.Errorf("removing %s: %w", it.Dir, err) + } + m.RemoveInterpreter(key) // also clears active if it was active + + if wasActive { + binDir := m.BinDir + if binDir == "" { + if binDir, _ = platform.SelectBinDir(layout.BinCandidates); binDir == "" { + binDir = filepath.Dir(it.Dir) + } + } + if err := reassignActive(opts, m, env, binDir); err != nil { + return err + } + } + + if err := m.Save(layout.ManifestPath()); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + opts.logf("Uninstalled interpreter php %s (%s).", it.Series, threading(it.ZTS)) + return nil +} + +// reassignActive decides what `php` points at after the active interpreter was +// removed: activate the highest remaining variant, or restore a backed-up +// original, or remove the symlink. +func reassignActive(opts Options, m *manifest.Manifest, env platform.Env, binDir string) error { + if remaining := m.InterpreterKeys(); len(remaining) > 0 { + newKey := highestKey(remaining) + nit, _ := m.Interpreter(newKey) + target := filepath.Join(nit.Dir, "bin", phpBinaryName(env.OS)) + if _, _, err := platform.Activate(binDir, "php", target); err != nil { + return fmt.Errorf("activating php %s: %w", newKey, err) + } + m.SetActive(newKey) + opts.logf("Switched active php -> %s (%s).", nit.Series, threading(nit.ZTS)) + return nil + } + + // No variants left: restore the displaced original if we have one. + if bkey, b, ok := anyBackup(m); ok { + _ = platform.RemoveActive(binDir, "php") + if err := restoreBackup(b.BackupPath, b.OriginalPath); err != nil { + return fmt.Errorf("restoring backup to %s: %w", b.OriginalPath, err) + } + m.RemoveBackup(bkey) + opts.logf("Restored the original interpreter at %s.", b.OriginalPath) + return nil + } + + if err := platform.RemoveActive(binDir, "php"); err != nil { + return fmt.Errorf("removing active php: %w", err) + } + opts.logf("Removed the active php entry.") + return nil +} + +func uninstallExtension(opts Options, layout platform.Layout, m *manifest.Manifest) error { + ext := m.Extension + if ext == nil { + return errors.New("no extension installed") + } + + if ext.IniPath != "" { + if err := removeExtensionLoader(ext.IniPath, ext.SoPath); err != nil { + return fmt.Errorf("removing loader from %s: %w", ext.IniPath, err) + } + } + if ext.SoPath != "" { + if err := removeIfExists(ext.SoPath); err != nil { + return fmt.Errorf("removing %s: %w", ext.SoPath, err) + } + } + m.ClearExtension() + if err := m.Save(layout.ManifestPath()); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + opts.logf("Uninstalled the php-debugger extension for php %s.", ext.Series) + return nil +} + +// removeExtensionLoader removes the debugger loader from an ini file: it drops +// the zend_extension line pointing at soPath and our comment line. If the file +// becomes effectively empty (it was a dedicated loader file) it is removed; +// otherwise it is rewritten (the loader was appended to a shared php.ini). +func removeExtensionLoader(iniPath, soPath string) error { + data, err := os.ReadFile(iniPath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var kept []string + for _, ln := range strings.Split(string(data), "\n") { + if soPath != "" && strings.Contains(ln, soPath) { + continue + } + if strings.HasPrefix(strings.TrimSpace(ln), "; Enables the php-debugger extension") { + continue + } + kept = append(kept, ln) + } + result := strings.Join(kept, "\n") + if strings.TrimSpace(result) == "" { + return os.Remove(iniPath) + } + return os.WriteFile(iniPath, []byte(result), 0o644) +} + +// anyBackup returns any backup recorded in the manifest (there is at most one +// meaningful one: the interpreter displaced when we first took over a location). +func anyBackup(m *manifest.Manifest) (string, manifest.Backup, bool) { + for k := range m.Backups { + b, _ := m.Backup(k) + return k, b, true + } + return "", manifest.Backup{}, false +} + +// highestKey returns the variant key with the highest PHP version. +func highestKey(keys []string) string { + best := keys[0] + for _, k := range keys[1:] { + if release.CompareSeries(seriesOf(k), seriesOf(best)) > 0 { + best = k + } + } + return best +} + +func seriesOf(key string) string { return strings.TrimSuffix(key, "-zts") } diff --git a/internal/installer/uninstall_test.go b/internal/installer/uninstall_test.go new file mode 100644 index 0000000..16fd2e6 --- /dev/null +++ b/internal/installer/uninstall_test.go @@ -0,0 +1,238 @@ +package installer + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +func TestUninstallInterpreterRestoresBackup(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + newCfg := filepath.Join(t.TempDir(), "newcfg") + srv := newFakeReleaseServer(t, fakePHP("8.3.7", true, newCfg, filepath.Join(newCfg, "conf.d"))) + + // A pre-existing php that will be replaced (and backed up). + existBin := filepath.Join(t.TempDir(), "bin") + existPhp := filepath.Join(existBin, "php") + existIni := t.TempDir() + writeExec(t, existPhp, existingPHPScript("8.2.9", filepath.Join(existIni, "php.ini"), + filepath.Join(existIni, "conf.d"), "")) + if err := os.WriteFile(filepath.Join(existIni, "php.ini"), []byte("memory_limit=64M\n"), 0o644); err != nil { + t.Fatal(err) + } + origContent, _ := os.ReadFile(existPhp) + t.Setenv("PATH", existBin) + + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + // Install (replaces existing, records a backup). + if err := InstallInterpreter(context.Background(), Options{ + Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env, + }); err != nil { + t.Fatalf("install: %v", err) + } + root := filepath.Join(home, ".local", "share", "php-debugger") + + // Sanity: our symlink took over. + if isLink, _ := platform.IsSymlink(existPhp); !isLink { + t.Fatal("expected our symlink at the existing php location") + } + + // Uninstall the active interpreter. + var out bytes.Buffer + if err := Uninstall(context.Background(), Options{ + Scope: platform.User, Out: &out, Env: &env, + }, false, false, "", false); err != nil { + t.Fatalf("uninstall: %v\n%s", err, out.String()) + } + + // Original interpreter restored at its location. + restored, err := os.ReadFile(existPhp) + if err != nil { + t.Fatalf("original not restored: %v", err) + } + if !bytes.Equal(restored, origContent) { + t.Error("restored interpreter differs from original") + } + if isLink, _ := platform.IsSymlink(existPhp); isLink { + t.Error("restored path should be a real file, not a symlink") + } + // Versioned dir and copied config removed. + if _, err := os.Stat(filepath.Join(root, "8.3")); !os.IsNotExist(err) { + t.Error("version dir should be removed") + } + if _, err := os.Stat(filepath.Join(newCfg, "php.ini")); !os.IsNotExist(err) { + t.Error("copied config should be removed") + } + // Manifest cleared. + m, _ := manifest.Load(filepath.Join(root, "manifest.json")) + if _, ok := m.Interpreter("8.3"); ok { + t.Error("interpreter should be gone from manifest") + } + if _, _, ok := anyBackup(m); ok { + t.Error("backup should be consumed after restore") + } +} + +func TestUninstallActiveReassignsToOtherVariant(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + srv := newMultiVersionServer(t) + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + // Install 8.3 then 8.4 (8.4 active) via switch. + for _, v := range []string{"8.3", "8.4"} { + o := Options{Scope: platform.User, Client: client, Env: &env, Out: &bytes.Buffer{}, PHPVersion: v} + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch %s: %v", v, err) + } + } + root := filepath.Join(home, ".local", "share", "php-debugger") + link := filepath.Join(home, ".local", "bin", "php") + + // Uninstall the active 8.4 -> 8.3 should become active. + var out bytes.Buffer + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &out, Env: &env}, + false, false, "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + + m, _ := manifest.Load(filepath.Join(root, "manifest.json")) + if m.Active() != "8.3" { + t.Errorf("active = %q, want 8.3", m.Active()) + } + tgt, _ := os.Readlink(link) + if tgt != filepath.Join(root, "8.3", "bin", "php") { + t.Errorf("symlink -> %q, want 8.3", tgt) + } + if _, err := os.Stat(filepath.Join(root, "8.4")); !os.IsNotExist(err) { + t.Error("8.4 dir should be removed") + } +} + +func TestUninstallCleanHostRemovesSymlink(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + srv := newFakeReleaseServer(t, fakePHP("8.3.7", true, "", "")) + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + if err := InstallInterpreter(context.Background(), Options{ + Scope: platform.User, Out: &bytes.Buffer{}, Client: client, Env: &env, PHPVersion: "8.3", + }); err != nil { + t.Fatalf("install: %v", err) + } + link := filepath.Join(home, ".local", "bin", "php") + + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + false, false, "", false); err != nil { + t.Fatalf("uninstall: %v", err) + } + if _, err := os.Lstat(link); !os.IsNotExist(err) { + t.Error("active symlink should be removed on clean-host uninstall") + } +} + +func TestUninstallExtension(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + extDir := t.TempDir() + iniDir := t.TempDir() + scanDir := filepath.Join(iniDir, "conf.d") + loadedFile := filepath.Join(iniDir, "php.ini") + if err := os.WriteFile(loadedFile, []byte("memory_limit=100M\n"), 0o644); err != nil { + t.Fatal(err) + } + binDir := filepath.Join(t.TempDir(), "bin") + writeExec(t, filepath.Join(binDir, "php"), + fakeExistingPHPForExt("8.3.7", extDir, loadedFile, scanDir, true)) + t.Setenv("PATH", binDir) + + srv := newFakeReleaseServer(t, "unused") + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + if err := InstallExtension(context.Background(), Options{ + Scope: platform.User, AssumeYes: true, Out: &bytes.Buffer{}, Client: client, Env: &env, + }); err != nil { + t.Fatalf("install extension: %v", err) + } + soDst := filepath.Join(extDir, "php-debugger-php8.3-nts-linux-x86_64.so") + loader := filepath.Join(scanDir, "99-php-debugger.ini") + + // Uninstall the extension. + if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env}, + false, false, "", false); err != nil { + t.Fatalf("uninstall extension: %v", err) + } + if _, err := os.Stat(soDst); !os.IsNotExist(err) { + t.Error(".so should be removed") + } + if _, err := os.Stat(loader); !os.IsNotExist(err) { + t.Error("dedicated loader ini should be removed") + } + root := filepath.Join(home, ".local", "share", "php-debugger") + m, _ := manifest.Load(filepath.Join(root, "manifest.json")) + if m.Extension != nil { + t.Error("extension should be cleared from manifest") + } +} + +func TestUninstallNothing(t *testing.T) { + env := linuxUserEnv(t.TempDir()) + err := Uninstall(context.Background(), Options{Scope: platform.User, Env: &env}, + false, false, "", false) + if err == nil || !strings.Contains(err.Error(), "nothing installed") { + t.Errorf("expected 'nothing installed', got %v", err) + } +} + +func TestRemoveExtensionLoaderKeepsSharedIni(t *testing.T) { + dir := t.TempDir() + ini := filepath.Join(dir, "php.ini") + so := "/ext/php-debugger.so" + content := "memory_limit=256M\n; Enables the php-debugger extension (added by php-debugger)\nzend_extension=" + so + "\ndisplay_errors=On\n" + if err := os.WriteFile(ini, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + if err := removeExtensionLoader(ini, so); err != nil { + t.Fatal(err) + } + // Shared ini kept, loader lines gone, other settings preserved. + got, err := os.ReadFile(ini) + if err != nil { + t.Fatalf("shared ini should be kept: %v", err) + } + s := string(got) + if strings.Contains(s, so) || strings.Contains(s, "Enables the php-debugger") { + t.Errorf("loader lines not removed:\n%s", s) + } + if !strings.Contains(s, "memory_limit=256M") || !strings.Contains(s, "display_errors=On") { + t.Errorf("other settings should be preserved:\n%s", s) + } +} diff --git a/internal/release/naming.go b/internal/release/naming.go index 1b2594c..a4b8bb6 100644 --- a/internal/release/naming.go +++ b/internal/release/naming.go @@ -162,6 +162,9 @@ func LatestSeries(assets []Asset, kind Kind, zts bool, osID platform.OS, arch pl return best, nil } +// CompareSeries compares two dotted version strings numerically (-1, 0, 1). +func CompareSeries(a, b string) int { return compareSeries(a, b) } + // compareSeries compares two dotted version strings numerically. Returns -1, 0 // or 1. Non-numeric fields sort as 0. func compareSeries(a, b string) int {