Skip to content

Commit af1d916

Browse files
Final polish (#14)
1 parent 2c42f0f commit af1d916

4 files changed

Lines changed: 263 additions & 3 deletions

File tree

‎README.md‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# php-debugger installer
2+
3+
A cross-platform command-line installer for the [PHP debugger](https://github.com/php-debugger/php-debugger).
4+
It installs either a self-contained PHP interpreter with the debugger compiled in
5+
(the default), or just the debugger extension into your existing PHP. It always
6+
pulls the **latest release** and auto-detects your OS and architecture.
7+
8+
## Installation
9+
10+
Build from source (Go 1.25+):
11+
12+
```bash
13+
go build -o php-debugger .
14+
# optionally move it onto your PATH
15+
mv php-debugger /usr/local/bin/ # or ~/.local/bin, etc.
16+
```
17+
18+
## Quick start
19+
20+
```bash
21+
# Install the latest debugger interpreter (replacing your current php, with a backup)
22+
php-debugger install
23+
24+
# Or install just the extension into your current php
25+
php-debugger install --extension-only
26+
27+
# Switch the active PHP version (installs it if needed)
28+
php-debugger switch 8.4
29+
30+
# Update to the latest release
31+
php-debugger update
32+
33+
# Remove it (restoring the interpreter you had before, if any)
34+
php-debugger uninstall
35+
```
36+
37+
## Commands
38+
39+
| Command | What it does |
40+
| --- | --- |
41+
| `install` | Install the debugger **interpreter** (default) or the **extension** (`-e`). |
42+
| `switch <version>` | Make an installed version active, installing it first if needed. |
43+
| `update` | Reinstall the active interpreter and/or extension against the latest release. |
44+
| `uninstall [version]` | Remove an interpreter (or the extension), restoring any backup. |
45+
46+
### Flags
47+
48+
Global:
49+
50+
- `-u, --user` — install into a per-user directory (no sudo). Default is system-wide.
51+
- `-y, --yes` — assume "yes" for prompts (non-interactive / CI).
52+
- `-V, --verbose` — extra output.
53+
54+
`install`:
55+
56+
- `-p, --php <x.y>` — PHP version to install (default: latest; interpreter only).
57+
- `-z, --zts` — thread-safe build (default: non-thread-safe; interpreter only).
58+
- `-e, --extension-only` — install only the extension into the current php.
59+
60+
`update` / `uninstall`:
61+
62+
- `-e, --extension` / `-i, --interpreter` — pick the target when both are installed.
63+
- `uninstall` also takes an optional `<version>` and `-z, --zts` to target a variant.
64+
65+
## Install locations
66+
67+
Interpreters are installed under a versioned directory and the active one is
68+
symlinked onto your PATH.
69+
70+
**System-wide (default, needs sudo/admin):**
71+
72+
| Platform | Install root | Active `php` |
73+
| --- | --- | --- |
74+
| macOS arm64 | `/opt/php-debugger` | `/opt/homebrew/bin` (or `/usr/local/bin`) |
75+
| macOS Intel / Linux | `/usr/local/php-debugger` | `/usr/local/bin` |
76+
| Windows | `%ProgramFiles%\php-debugger` | `<root>\bin` |
77+
78+
**Per-user (`--user`, no sudo):**
79+
80+
| Platform | Install root | Active `php` |
81+
| --- | --- | --- |
82+
| macOS | `~/Library/Application Support/php-debugger` | `~/.local/bin` |
83+
| Linux | `$XDG_DATA_HOME` or `~/.local/share/php-debugger` | `~/.local/bin` |
84+
| Windows | `%LOCALAPPDATA%\php-debugger` | `<root>\bin` |
85+
86+
If the chosen bin directory isn't on your `PATH`, the installer prints the exact
87+
line to add.
88+
89+
## How it works
90+
91+
- **Verification first.** The downloaded interpreter is run (`php -v`) before any
92+
change is made. If it can't run on your system, nothing is installed and you're
93+
advised to use `--extension-only` or build from source.
94+
- **Replace with a backup.** If you already have a `php`, it is backed up and
95+
replaced in place, so `php` immediately resolves to the debugger build.
96+
`uninstall` restores it.
97+
- **Config carried over.** Your existing ini configuration is copied to the new
98+
interpreter's config path. Xdebug loaders are removed (the debugger provides its
99+
own simulated xdebug), other extension loaders are commented out (a
100+
self-contained build can't load foreign `.so` files), and `xdebug.mode` is
101+
constrained to `off`/`debug` (with a prompt if other modes are present).
102+
- **Post-install check + rollback.** After activating, it confirms the new `php`
103+
runs and reports the `php_debugger` module. Any failure rolls everything back to
104+
the prior working state.
105+
- **Multiple versions coexist.** `switch` flips the active version instantly via
106+
the symlink; installed versions are kept side by side.
107+
108+
## Platform support
109+
110+
Linux, macOS and Windows; x86_64 and arm64. On Windows, where symlinks require
111+
elevated privileges, the active `php` falls back to a generated `.cmd` shim. On
112+
Apple Silicon, an x86_64 build still installs native arm64 binaries.
113+
114+
## Development
115+
116+
```bash
117+
go build ./...
118+
go test -race ./...
119+
gofmt -l .
120+
```
121+
122+
The code is organized under `internal/`: `platform` (OS/arch, paths, symlinks),
123+
`release` (GitHub API + asset selection), `php` (interpreter introspection),
124+
`ini` (config rewriting), `manifest` (on-disk state), `installer` (orchestration),
125+
and `cli` (commands). A hidden `php-debugger resolve` command prints what would be
126+
downloaded for the current host — handy for debugging.

‎internal/installer/cleanup.go‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package installer
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"sort"
7+
8+
"github.com/php-debugger/installer/internal/manifest"
9+
"github.com/php-debugger/installer/internal/platform"
10+
)
11+
12+
// finalizeManifest saves the manifest, or removes the whole install root when
13+
// nothing is installed any more (so a full uninstall leaves no empty leftovers).
14+
func finalizeManifest(layout platform.Layout, m *manifest.Manifest) error {
15+
if manifestEmpty(m) {
16+
return os.RemoveAll(layout.Root)
17+
}
18+
return m.Save(layout.ManifestPath())
19+
}
20+
21+
func manifestEmpty(m *manifest.Manifest) bool {
22+
return len(m.Interpreters) == 0 && len(m.Backups) == 0 &&
23+
m.Extension == nil && m.Active() == ""
24+
}
25+
26+
// pruneEmptyConfigDirs removes now-empty directories that held copied config
27+
// files (e.g. the compiled-config path and its conf.d), deepest first. os.Remove
28+
// only succeeds on empty dirs, so shared directories with other content are left
29+
// intact.
30+
func pruneEmptyConfigDirs(files []string) {
31+
seen := map[string]bool{}
32+
var dirs []string
33+
for _, f := range files {
34+
d := filepath.Dir(f)
35+
for i := 0; i < 2; i++ { // the file's dir and one parent
36+
if d == "" || d == "/" || d == "." {
37+
break
38+
}
39+
if !seen[d] {
40+
seen[d] = true
41+
dirs = append(dirs, d)
42+
}
43+
d = filepath.Dir(d)
44+
}
45+
}
46+
// Deeper paths (longer strings under a common root) first.
47+
sort.Slice(dirs, func(i, j int) bool { return len(dirs[i]) > len(dirs[j]) })
48+
for _, d := range dirs {
49+
_ = os.Remove(d)
50+
}
51+
}

‎internal/installer/cleanup_test.go‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package installer
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
"testing"
10+
11+
"github.com/php-debugger/installer/internal/platform"
12+
"github.com/php-debugger/installer/internal/release"
13+
)
14+
15+
func TestPruneEmptyConfigDirs(t *testing.T) {
16+
root := t.TempDir()
17+
php85 := filepath.Join(root, "php", "8.5")
18+
confd := filepath.Join(php85, "conf.d")
19+
if err := os.MkdirAll(confd, 0o755); err != nil {
20+
t.Fatal(err)
21+
}
22+
// Another version dir with content that must survive.
23+
php82 := filepath.Join(root, "php", "8.2")
24+
if err := os.MkdirAll(php82, 0o755); err != nil {
25+
t.Fatal(err)
26+
}
27+
if err := os.WriteFile(filepath.Join(php82, "php.ini"), []byte("x"), 0o644); err != nil {
28+
t.Fatal(err)
29+
}
30+
31+
// Config files we "removed" already (they no longer exist); prune their dirs.
32+
files := []string{
33+
filepath.Join(php85, "php.ini"),
34+
filepath.Join(confd, "20-xdebug.ini"),
35+
}
36+
pruneEmptyConfigDirs(files)
37+
38+
if _, err := os.Stat(confd); !os.IsNotExist(err) {
39+
t.Error("empty conf.d should be pruned")
40+
}
41+
if _, err := os.Stat(php85); !os.IsNotExist(err) {
42+
t.Error("empty 8.5 dir should be pruned")
43+
}
44+
// Shared parent and the other version must remain.
45+
if _, err := os.Stat(php82); err != nil {
46+
t.Error("non-empty 8.2 dir must be kept")
47+
}
48+
if _, err := os.Stat(filepath.Join(root, "php")); err != nil {
49+
t.Error("shared php dir with other content must be kept")
50+
}
51+
}
52+
53+
func TestUninstallRemovesEmptyRoot(t *testing.T) {
54+
if runtime.GOOS == "windows" {
55+
t.Skip("fake php is a /bin/sh script")
56+
}
57+
isolatePATH(t)
58+
home := t.TempDir()
59+
srv := newFakeReleaseServer(t, fakePHP("8.3.7", true, "", ""))
60+
client := release.NewClient()
61+
client.BaseURL = srv.URL
62+
env := linuxUserEnv(home)
63+
64+
if err := InstallInterpreter(context.Background(), Options{
65+
Scope: platform.User, Out: &bytes.Buffer{}, Client: client, Env: &env, PHPVersion: "8.3",
66+
}); err != nil {
67+
t.Fatalf("install: %v", err)
68+
}
69+
root := filepath.Join(home, ".local", "share", "php-debugger")
70+
if _, err := os.Stat(root); err != nil {
71+
t.Fatalf("root should exist after install: %v", err)
72+
}
73+
74+
if err := Uninstall(context.Background(), Options{Scope: platform.User, Out: &bytes.Buffer{}, Env: &env},
75+
false, false, "", false); err != nil {
76+
t.Fatalf("uninstall: %v", err)
77+
}
78+
if _, err := os.Stat(root); !os.IsNotExist(err) {
79+
t.Error("install root should be removed after full uninstall")
80+
}
81+
}

‎internal/installer/uninstall.go‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,14 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani
7373
}
7474
wasActive := m.Active() == key
7575

76-
// Remove the copied config files, then the versioned directory.
76+
// Remove the copied config files (and prune the dirs they lived in if now
77+
// empty), then the versioned directory.
7778
for _, f := range it.ConfigFiles {
7879
if err := removeIfExists(f); err != nil {
7980
return fmt.Errorf("removing config %s: %w", f, err)
8081
}
8182
}
83+
pruneEmptyConfigDirs(it.ConfigFiles)
8284
if err := os.RemoveAll(it.Dir); err != nil {
8385
return fmt.Errorf("removing %s: %w", it.Dir, err)
8486
}
@@ -96,7 +98,7 @@ func uninstallInterpreter(opts Options, layout platform.Layout, m *manifest.Mani
9698
}
9799
}
98100

99-
if err := m.Save(layout.ManifestPath()); err != nil {
101+
if err := finalizeManifest(layout, m); err != nil {
100102
return fmt.Errorf("saving manifest: %w", err)
101103
}
102104
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
154156
}
155157
}
156158
m.ClearExtension()
157-
if err := m.Save(layout.ManifestPath()); err != nil {
159+
if err := finalizeManifest(layout, m); err != nil {
158160
return fmt.Errorf("saving manifest: %w", err)
159161
}
160162
opts.logf("Uninstalled the php-debugger extension for php %s.", ext.Series)

0 commit comments

Comments
 (0)