Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions internal/cli/uninstall.go
Original file line numberDiff line numberDiff line change
@@ -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 {
Expand All@@ -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 {
Expand All@@ -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
}
214 changes: 214 additions & 0 deletions internal/installer/uninstall.go
Original file line numberDiff line numberDiff line change
@@ -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") }
Loading
Loading