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
126 changes: 126 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <version>` | 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 <x.y>` — 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 `<version>` 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` | `<root>\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` | `<root>\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.
51 changes: 51 additions & 0 deletions internal/installer/cleanup.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
81 changes: 81 additions & 0 deletions internal/installer/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 5 additions & 3 deletions internal/installer/uninstall.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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))
Expand Down Expand Up @@ -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)
Expand Down
Loading