diff --git a/README.md b/README.md new file mode 100644 index 0000000..c52b930 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# php-debugger installer + +A cross-platform command-line installer for the [PHP debugger](https://github.com/php-debugger/php-debugger). +It installs either a self-contained PHP interpreter with the debugger compiled in +(the default), or just the debugger extension into your existing PHP. It always +pulls the **latest release** and auto-detects your OS and architecture. + +## Installation + +Build from source (Go 1.25+): + +```bash +go build -o php-debugger . +# optionally move it onto your PATH +mv php-debugger /usr/local/bin/ # or ~/.local/bin, etc. +``` + +## Quick start + +```bash +# Install the latest debugger interpreter (replacing your current php, with a backup) +php-debugger install + +# Or install just the extension into your current php +php-debugger install --extension-only + +# Switch the active PHP version (installs it if needed) +php-debugger switch 8.4 + +# Update to the latest release +php-debugger update + +# Remove it (restoring the interpreter you had before, if any) +php-debugger uninstall +``` + +## Commands + +| Command | What it does | +| --- | --- | +| `install` | Install the debugger **interpreter** (default) or the **extension** (`-e`). | +| `switch ` | Make an installed version active, installing it first if needed. | +| `update` | Reinstall the active interpreter and/or extension against the latest release. | +| `uninstall [version]` | Remove an interpreter (or the extension), restoring any backup. | + +### Flags + +Global: + +- `-u, --user` — install into a per-user directory (no sudo). Default is system-wide. +- `-y, --yes` — assume "yes" for prompts (non-interactive / CI). +- `-V, --verbose` — extra output. + +`install`: + +- `-p, --php ` — PHP version to install (default: latest; interpreter only). +- `-z, --zts` — thread-safe build (default: non-thread-safe; interpreter only). +- `-e, --extension-only` — install only the extension into the current php. + +`update` / `uninstall`: + +- `-e, --extension` / `-i, --interpreter` — pick the target when both are installed. +- `uninstall` also takes an optional `` and `-z, --zts` to target a variant. + +## Install locations + +Interpreters are installed under a versioned directory and the active one is +symlinked onto your PATH. + +**System-wide (default, needs sudo/admin):** + +| Platform | Install root | Active `php` | +| --- | --- | --- | +| macOS arm64 | `/opt/php-debugger` | `/opt/homebrew/bin` (or `/usr/local/bin`) | +| macOS Intel / Linux | `/usr/local/php-debugger` | `/usr/local/bin` | +| Windows | `%ProgramFiles%\php-debugger` | `\bin` | + +**Per-user (`--user`, no sudo):** + +| Platform | Install root | Active `php` | +| --- | --- | --- | +| macOS | `~/Library/Application Support/php-debugger` | `~/.local/bin` | +| Linux | `$XDG_DATA_HOME` or `~/.local/share/php-debugger` | `~/.local/bin` | +| Windows | `%LOCALAPPDATA%\php-debugger` | `\bin` | + +If the chosen bin directory isn't on your `PATH`, the installer prints the exact +line to add. + +## How it works + +- **Verification first.** The downloaded interpreter is run (`php -v`) before any + change is made. If it can't run on your system, nothing is installed and you're + advised to use `--extension-only` or build from source. +- **Replace with a backup.** If you already have a `php`, it is backed up and + replaced in place, so `php` immediately resolves to the debugger build. + `uninstall` restores it. +- **Config carried over.** Your existing ini configuration is copied to the new + interpreter's config path. Xdebug loaders are removed (the debugger provides its + own simulated xdebug), other extension loaders are commented out (a + self-contained build can't load foreign `.so` files), and `xdebug.mode` is + constrained to `off`/`debug` (with a prompt if other modes are present). +- **Post-install check + rollback.** After activating, it confirms the new `php` + runs and reports the `php_debugger` module. Any failure rolls everything back to + the prior working state. +- **Multiple versions coexist.** `switch` flips the active version instantly via + the symlink; installed versions are kept side by side. + +## Platform support + +Linux, macOS and Windows; x86_64 and arm64. On Windows, where symlinks require +elevated privileges, the active `php` falls back to a generated `.cmd` shim. On +Apple Silicon, an x86_64 build still installs native arm64 binaries. + +## Development + +```bash +go build ./... +go test -race ./... +gofmt -l . +``` + +The code is organized under `internal/`: `platform` (OS/arch, paths, symlinks), +`release` (GitHub API + asset selection), `php` (interpreter introspection), +`ini` (config rewriting), `manifest` (on-disk state), `installer` (orchestration), +and `cli` (commands). A hidden `php-debugger resolve` command prints what would be +downloaded for the current host — handy for debugging. diff --git a/internal/installer/cleanup.go b/internal/installer/cleanup.go new file mode 100644 index 0000000..b16ebe0 --- /dev/null +++ b/internal/installer/cleanup.go @@ -0,0 +1,51 @@ +package installer + +import ( + "os" + "path/filepath" + "sort" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" +) + +// finalizeManifest saves the manifest, or removes the whole install root when +// nothing is installed any more (so a full uninstall leaves no empty leftovers). +func finalizeManifest(layout platform.Layout, m *manifest.Manifest) error { + if manifestEmpty(m) { + return os.RemoveAll(layout.Root) + } + return m.Save(layout.ManifestPath()) +} + +func manifestEmpty(m *manifest.Manifest) bool { + return len(m.Interpreters) == 0 && len(m.Backups) == 0 && + m.Extension == nil && m.Active() == "" +} + +// pruneEmptyConfigDirs removes now-empty directories that held copied config +// files (e.g. the compiled-config path and its conf.d), deepest first. os.Remove +// only succeeds on empty dirs, so shared directories with other content are left +// intact. +func pruneEmptyConfigDirs(files []string) { + seen := map[string]bool{} + var dirs []string + for _, f := range files { + d := filepath.Dir(f) + for i := 0; i < 2; i++ { // the file's dir and one parent + if d == "" || d == "/" || d == "." { + break + } + if !seen[d] { + seen[d] = true + dirs = append(dirs, d) + } + d = filepath.Dir(d) + } + } + // Deeper paths (longer strings under a common root) first. + sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) }) + for _, d := range dirs { + _ = os.Remove(d) + } +} diff --git a/internal/installer/cleanup_test.go b/internal/installer/cleanup_test.go new file mode 100644 index 0000000..a6f9fa5 --- /dev/null +++ b/internal/installer/cleanup_test.go @@ -0,0 +1,81 @@ +package installer + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +func TestPruneEmptyConfigDirs(t *testing.T) { + root := t.TempDir() + php85 := filepath.Join(root, "php", "8.5") + confd := filepath.Join(php85, "conf.d") + if err := os.MkdirAll(confd, 0o755); err != nil { + t.Fatal(err) + } + // Another version dir with content that must survive. + php82 := filepath.Join(root, "php", "8.2") + if err := os.MkdirAll(php82, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(php82, "php.ini"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + // Config files we "removed" already (they no longer exist); prune their dirs. + files := []string{ + filepath.Join(php85, "php.ini"), + filepath.Join(confd, "20-xdebug.ini"), + } + pruneEmptyConfigDirs(files) + + if _, err := os.Stat(confd); !os.IsNotExist(err) { + t.Error("empty conf.d should be pruned") + } + if _, err := os.Stat(php85); !os.IsNotExist(err) { + t.Error("empty 8.5 dir should be pruned") + } + // Shared parent and the other version must remain. + if _, err := os.Stat(php82); err != nil { + t.Error("non-empty 8.2 dir must be kept") + } + if _, err := os.Stat(filepath.Join(root, "php")); err != nil { + t.Error("shared php dir with other content must be kept") + } +} + +func TestUninstallRemovesEmptyRoot(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) + } + root := filepath.Join(home, ".local", "share", "php-debugger") + if _, err := os.Stat(root); err != nil { + t.Fatalf("root should exist after install: %v", err) + } + + 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.Stat(root); !os.IsNotExist(err) { + t.Error("install root should be removed after full uninstall") + } +} diff --git a/internal/installer/uninstall.go b/internal/installer/uninstall.go index 44ae764..9ec57b4 100644 --- a/internal/installer/uninstall.go +++ b/internal/installer/uninstall.go @@ -73,12 +73,14 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani } wasActive := m.Active() == key - // Remove the copied config files, then the versioned directory. + // Remove the copied config files (and prune the dirs they lived in if now + // empty), then the versioned directory. for _, f := range it.ConfigFiles { if err := removeIfExists(f); err != nil { return fmt.Errorf("removing config %s: %w", f, err) } } + pruneEmptyConfigDirs(it.ConfigFiles) if err := os.RemoveAll(it.Dir); err != nil { return fmt.Errorf("removing %s: %w", it.Dir, err) } @@ -96,7 +98,7 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani } } - if err := m.Save(layout.ManifestPath()); err != nil { + if err := finalizeManifest(layout, m); err != nil { return fmt.Errorf("saving manifest: %w", err) } opts.logf("Uninstalled interpreter php %s (%s).", it.Series, threading(it.ZTS)) @@ -154,7 +156,7 @@ func uninstallExtension(opts Options, layout platform.Layout, m *manifest.Manife } } m.ClearExtension() - if err := m.Save(layout.ManifestPath()); err != nil { + if err := finalizeManifest(layout, m); err != nil { return fmt.Errorf("saving manifest: %w", err) } opts.logf("Uninstalled the php-debugger extension for php %s.", ext.Series)