From fd1e122e7a4a283e511af4dcfda1b50f334bdf55 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Sat, 25 Jul 2026 22:00:02 +0200 Subject: [PATCH] Installer package --- internal/cli/install.go | 18 ++- internal/installer/fsutil.go | 41 +++++ internal/installer/install.go | 243 ++++++++++++++++++++++++++++ internal/installer/install_test.go | 209 ++++++++++++++++++++++++ internal/installer/rollback.go | 21 +++ internal/installer/rollback_test.go | 52 ++++++ internal/php/php.go | 12 +- 7 files changed, 592 insertions(+), 4 deletions(-) create mode 100644 internal/installer/fsutil.go create mode 100644 internal/installer/install.go create mode 100644 internal/installer/install_test.go create mode 100644 internal/installer/rollback.go create mode 100644 internal/installer/rollback_test.go diff --git a/internal/cli/install.go b/internal/cli/install.go index 0100913..70560cd 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.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" +) // installOptions holds flags specific to the install command. type installOptions struct { @@ -26,7 +30,17 @@ func newInstallCmd() *cobra.Command { "into the currently active PHP.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return errNotImplemented("install") + if opts.ExtensionOnly { + // Extension-only install is implemented in a later step. + return errNotImplemented("extension install") + } + return installer.InstallInterpreter(cmd.Context(), installer.Options{ + Scope: platform.ScopeFromUserFlag(globalOpts.User), + PHPVersion: opts.PHPVersion, + ZTS: opts.ZTS, + AssumeYes: globalOpts.Yes, + Out: cmd.OutOrStdout(), + }) }, } diff --git a/internal/installer/fsutil.go b/internal/installer/fsutil.go new file mode 100644 index 0000000..e8d10bb --- /dev/null +++ b/internal/installer/fsutil.go @@ -0,0 +1,41 @@ +package installer + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// installFile copies src to dst (creating parent directories) and marks it +// executable. +func installFile(src, dst string) error { + return copyFile(src, dst, 0o755) +} + +// copyFile copies src to dst with the given permissions, creating parent +// directories as needed. +func copyFile(src, dst string, perm os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return fmt.Errorf("creating %s: %w", filepath.Dir(dst), err) + } + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + // Ensure perms even if the file pre-existed with different mode. + return os.Chmod(dst, perm) +} diff --git a/internal/installer/install.go b/internal/installer/install.go new file mode 100644 index 0000000..a92d4dd --- /dev/null +++ b/internal/installer/install.go @@ -0,0 +1,243 @@ +// Package installer orchestrates installing, updating and removing the PHP +// debugger — wiring together platform detection, release resolution, download, +// verification, symlinking and the on-disk manifest, with rollback on failure. +package installer + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "time" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/php" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// Options configures an install. +type Options struct { + Scope platform.Scope + PHPVersion string // PHP series (e.g. "8.3"); empty means latest available + ZTS bool + AssumeYes bool + + // Out receives human-readable progress output (may be nil). + Out io.Writer + + // Client and Env are optional overrides for testing. When nil, real ones are + // constructed. + Client *release.Client + Env *platform.Env + + now func() time.Time // optional clock override for tests +} + +func (o Options) logf(format string, args ...any) { + if o.Out != nil { + fmt.Fprintf(o.Out, format+"\n", args...) + } +} + +func (o Options) env() (platform.Env, error) { + if o.Env != nil { + return *o.Env, nil + } + return platform.CurrentEnv() +} + +func (o Options) clock() time.Time { + if o.now != nil { + return o.now() + } + return time.Now().UTC() +} + +// InstallInterpreter installs a self-contained PHP interpreter with the debugger +// compiled in, and activates it. This is the clean-host path: it does not yet +// detect or back up a pre-existing interpreter (that is layered on in a later +// step). On any failure after the first filesystem change, it rolls back. +func InstallInterpreter(ctx context.Context, opts Options) error { + env, err := opts.env() + if err != nil { + return err + } + p := platform.Platform{OS: env.OS, Arch: env.Arch} + + layout, err := platform.Resolve(env, opts.Scope) + if err != nil { + return err + } + binDir, err := platform.SelectBinDir(layout.BinCandidates) + if err != nil { + return fmt.Errorf("%w\ntry --user for a per-user install, or re-run with elevated privileges", err) + } + + client := opts.Client + if client == nil { + client = release.NewClient() + } + + rel, err := client.LatestRelease(ctx) + if err != nil { + return err + } + + series := opts.PHPVersion + if series == "" { + series, err = release.LatestSeries(rel.Assets, release.Interpreter, opts.ZTS, p.OS, p.Arch) + if err != nil { + return err + } + } + + asset, err := release.SelectAsset(rel.Assets, release.Selector{ + Kind: release.Interpreter, + Series: series, + ZTS: opts.ZTS, + OS: p.OS, + Arch: p.Arch, + }) + if err != nil { + return err + } + + opts.logf("Installing php-debugger interpreter: php %s (%s) %s, release %s", + series, threading(opts.ZTS), p, rel.TagName) + + // --- download to a temp dir --- + tmpDir, err := os.MkdirTemp("", "php-debugger-dl-") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + + opts.logf("Downloading %s ...", asset.Name) + dlPath, err := client.Download(ctx, asset, tmpDir) + if err != nil { + return err + } + if err := os.Chmod(dlPath, 0o755); err != nil { + return err + } + + // --- smoke test before touching anything on the system --- + opts.logf("Verifying the interpreter runs on this system ...") + if err := php.SmokeTest(ctx, dlPath); err != nil { + return smokeFailureError(err) + } + info, err := php.Query(ctx, dlPath) + if err != nil { + return fmt.Errorf("querying downloaded interpreter: %w", err) + } + + // --- place into the versioned directory --- + rb := &rollback{} + versionDir := layout.VersionDir(series, opts.ZTS) + binTarget := filepath.Join(versionDir, "bin", phpBinaryName(p.OS)) + if err := installFile(dlPath, binTarget); err != nil { + return fmt.Errorf("installing interpreter binary: %w", err) + } + rb.add(func() error { return os.RemoveAll(versionDir) }) + + // --- activate (symlink/shim into the bin dir) --- + prevTarget, _, hadPrev := platform.ReadActive(binDir, "php") + activePath, kind, err := platform.Activate(binDir, "php", binTarget) + if err != nil { + rb.run() + return fmt.Errorf("activating interpreter: %w", err) + } + rb.add(func() error { + if hadPrev { + _, _, e := platform.Activate(binDir, "php", prevTarget) + return e + } + return platform.RemoveActive(binDir, "php") + }) + + // --- post-verify via the activated entry --- + opts.logf("Confirming the installed interpreter works and has the debugger ...") + if err := php.SmokeTest(ctx, activePath); err != nil { + rb.run() + return fmt.Errorf("installed interpreter failed to run; rolled back: %w", err) + } + hasDebugger, err := php.HasModule(ctx, activePath, php.DebuggerModule) + if err != nil { + rb.run() + return fmt.Errorf("checking for debugger module; rolled back: %w", err) + } + if !hasDebugger { + rb.run() + return fmt.Errorf("installed interpreter does not report the %q module; rolled back", + php.DebuggerModule) + } + + // --- record in the manifest --- + m, err := manifest.Load(layout.ManifestPath()) + if err != nil { + rb.run() + return err + } + key := platform.VersionDirName(series, opts.ZTS) + m.InstallRoot = layout.Root + m.BinDir = binDir + m.SetInterpreter(key, manifest.Interpreter{ + Series: series, + PHPVersion: info.Version, + ZTS: opts.ZTS, + ReleaseTag: rel.TagName, + Dir: versionDir, + InstalledAt: opts.clock(), + }) + m.SetActive(key) + if err := m.Save(layout.ManifestPath()); err != nil { + rb.run() + return fmt.Errorf("saving manifest; rolled back: %w", err) + } + + opts.logf("Installed php %s (%s). Active php -> %s", info.Version, kind, activePath) + warnIfNotOnPATH(opts, p.OS, binDir) + return nil +} + +func threading(zts bool) string { + if zts { + return "zts" + } + return "nts" +} + +func phpBinaryName(osID platform.OS) string { + if osID == platform.Windows { + return "php.exe" + } + return "php" +} + +func smokeFailureError(err error) error { + return fmt.Errorf(`the downloaded interpreter failed to run on this system: + +%w + +You can instead install only the debugger extension: + php-debugger install --extension-only +or build from source following the instructions at + https://github.com/php-debugger/php-debugger`, err) +} + +func warnIfNotOnPATH(opts Options, osID platform.OS, binDir string) { + if platform.IsOnPATH(osID, binDir, os.Getenv("PATH")) { + return + } + opts.logf("") + opts.logf("Note: %s is not on your PATH.", binDir) + if osID == platform.Windows { + opts.logf("Add it via System Properties > Environment Variables, or:") + opts.logf(` setx PATH "%s;%%PATH%%"`, binDir) + } else { + opts.logf("Add it to your shell profile, e.g.:") + opts.logf(` export PATH="%s:$PATH"`, binDir) + } +} diff --git a/internal/installer/install_test.go b/internal/installer/install_test.go new file mode 100644 index 0000000..dac1d86 --- /dev/null +++ b/internal/installer/install_test.go @@ -0,0 +1,209 @@ +package installer + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "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" +) + +// fakePHP is a /bin/sh script that impersonates a php binary well enough for the +// installer's smoke test, info query and module check. hasDebugger controls +// whether `-m` lists the debugger module (to exercise the rollback path). +func fakePHP(hasDebugger bool) string { + modules := "[PHP Modules]\\nCore\\ndate\\n" + if hasDebugger { + modules += "php_debugger\\n" + } + return `#!/bin/sh +case "$1" in + -v) echo "PHP 8.3.7 (cli) (built: Jan 1 2026) (NTS)" ;; + -m) printf '` + modules + `' ;; + --ini) echo "Loaded Configuration File: (none)" ;; + -r) printf 'version=8.3.7\nseries=8.3\nzts=0\nextension_dir=/fake/ext\n' ;; + *) : ;; +esac +exit 0 +` +} + +// newFakeReleaseServer serves a latest-release payload pointing at a single +// interpreter asset whose bytes are the given fake php script. +func newFakeReleaseServer(t *testing.T, phpScript string) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprintf(w, `{"tag_name":"9.9.9","assets":[ + {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q,"size":%d} + ]}`, srv.URL+"/dl/php", len(phpScript)) + case r.URL.Path == "/dl/php": + w.Write([]byte(phpScript)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func linuxUserEnv(home string) platform.Env { + return platform.Env{ + OS: platform.Linux, + Arch: platform.X8664, + Home: home, + Getenv: func(string) string { return "" }, + } +} + +func TestInstallInterpreterCleanHost(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + srv := newFakeReleaseServer(t, fakePHP(true)) + + client := release.NewClient() + client.BaseURL = srv.URL + + env := linuxUserEnv(home) + var out bytes.Buffer + err := InstallInterpreter(context.Background(), Options{ + Scope: platform.User, + Out: &out, + Client: client, + Env: &env, + }) + if err != nil { + t.Fatalf("InstallInterpreter: %v\n--- output ---\n%s", err, out.String()) + } + + root := filepath.Join(home, ".local", "share", "php-debugger") + binTarget := filepath.Join(root, "8.3", "bin", "php") + if _, err := os.Stat(binTarget); err != nil { + t.Errorf("interpreter binary not placed at %s: %v", binTarget, err) + } + + link := filepath.Join(home, ".local", "bin", "php") + got, err := os.Readlink(link) + if err != nil { + t.Fatalf("active symlink missing: %v", err) + } + if got != binTarget { + t.Errorf("symlink -> %q, want %q", got, binTarget) + } + + m, err := manifest.Load(filepath.Join(root, "manifest.json")) + if err != nil { + t.Fatal(err) + } + if m.Active() != "8.3" { + t.Errorf("manifest active = %q, want 8.3", m.Active()) + } + it, ok := m.Interpreter("8.3") + if !ok { + t.Fatal("interpreter 8.3 not recorded in manifest") + } + if it.PHPVersion != "8.3.7" || it.ReleaseTag != "9.9.9" { + t.Errorf("interpreter record = %+v", it) + } + if it.Dir != filepath.Join(root, "8.3") { + t.Errorf("interpreter dir = %q", it.Dir) + } +} + +func TestInstallInterpreterRollbackOnMissingModule(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + srv := newFakeReleaseServer(t, fakePHP(false)) // -m does NOT list php-debugger + + client := release.NewClient() + client.BaseURL = srv.URL + + env := linuxUserEnv(home) + var out bytes.Buffer + err := InstallInterpreter(context.Background(), Options{ + Scope: platform.User, + Out: &out, + Client: client, + Env: &env, + }) + if err == nil { + t.Fatalf("expected install to fail when module is missing\n%s", out.String()) + } + if !strings.Contains(err.Error(), "rolled back") { + t.Errorf("error should mention rollback, got: %v", err) + } + + root := filepath.Join(home, ".local", "share", "php-debugger") + + // version dir removed + if _, err := os.Stat(filepath.Join(root, "8.3")); !os.IsNotExist(err) { + t.Error("version directory should have been rolled back") + } + // symlink not left behind + if _, err := os.Lstat(filepath.Join(home, ".local", "bin", "php")); !os.IsNotExist(err) { + t.Error("active symlink should have been rolled back") + } + // manifest never written (save happens only after verification) + if _, err := os.Stat(filepath.Join(root, "manifest.json")); !os.IsNotExist(err) { + t.Error("manifest should not exist after a rolled-back install") + } +} + +func TestInstallInterpreterSmokeFailureNoChanges(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + home := t.TempDir() + // a "binary" that exits non-zero on -v + badPHP := "#!/bin/sh\nexit 3\n" + srv := newFakeReleaseServer(t, badPHP) + + client := release.NewClient() + client.BaseURL = srv.URL + + env := linuxUserEnv(home) + var out bytes.Buffer + err := InstallInterpreter(context.Background(), Options{ + Scope: platform.User, + Out: &out, + Client: client, + Env: &env, + }) + if err == nil { + t.Fatal("expected smoke-test failure") + } + if !strings.Contains(err.Error(), "--extension-only") { + t.Errorf("smoke failure should suggest the extension, got: %v", err) + } + // nothing should have been created under the install root + if _, err := os.Stat(filepath.Join(home, ".local", "share", "php-debugger", "8.3")); !os.IsNotExist(err) { + t.Error("no version dir should be created when the smoke test fails") + } +} + +func TestThreadingAndBinaryName(t *testing.T) { + if threading(true) != "zts" || threading(false) != "nts" { + t.Error("threading mapping wrong") + } + if phpBinaryName(platform.Windows) != "php.exe" { + t.Error("windows binary should be php.exe") + } + if phpBinaryName(platform.Linux) != "php" { + t.Error("unix binary should be php") + } +} diff --git a/internal/installer/rollback.go b/internal/installer/rollback.go new file mode 100644 index 0000000..4102e94 --- /dev/null +++ b/internal/installer/rollback.go @@ -0,0 +1,21 @@ +package installer + +// rollback records undo steps to run, in reverse order, if an install fails +// partway through. Each step is best-effort; errors during rollback are ignored +// so every step gets a chance to run. +type rollback struct { + undos []func() error +} + +// add registers an undo step. Steps are run in reverse of registration order. +func (r *rollback) add(undo func() error) { + r.undos = append(r.undos, undo) +} + +// run executes all registered undo steps in reverse order and clears them. +func (r *rollback) run() { + for i := len(r.undos) - 1; i >= 0; i-- { + _ = r.undos[i]() + } + r.undos = nil +} diff --git a/internal/installer/rollback_test.go b/internal/installer/rollback_test.go new file mode 100644 index 0000000..170f455 --- /dev/null +++ b/internal/installer/rollback_test.go @@ -0,0 +1,52 @@ +package installer + +import ( + "errors" + "testing" +) + +func TestRollbackRunsInReverse(t *testing.T) { + var order []int + rb := &rollback{} + rb.add(func() error { order = append(order, 1); return nil }) + rb.add(func() error { order = append(order, 2); return nil }) + rb.add(func() error { order = append(order, 3); return nil }) + + rb.run() + + want := []int{3, 2, 1} + if len(order) != len(want) { + t.Fatalf("order = %v, want %v", order, want) + } + for i := range want { + if order[i] != want[i] { + t.Fatalf("order = %v, want %v", order, want) + } + } +} + +func TestRollbackContinuesPastErrors(t *testing.T) { + var ran []int + rb := &rollback{} + rb.add(func() error { ran = append(ran, 1); return nil }) + rb.add(func() error { ran = append(ran, 2); return errors.New("boom") }) + rb.add(func() error { ran = append(ran, 3); return nil }) + + rb.run() + + // all three should have run despite the middle one erroring + if len(ran) != 3 { + t.Errorf("ran = %v, want all three steps", ran) + } +} + +func TestRollbackClearsAfterRun(t *testing.T) { + count := 0 + rb := &rollback{} + rb.add(func() error { count++; return nil }) + rb.run() + rb.run() // second run should be a no-op + if count != 1 { + t.Errorf("undo ran %d times, want 1", count) + } +} diff --git a/internal/php/php.go b/internal/php/php.go index 725adee..45060ab 100644 --- a/internal/php/php.go +++ b/internal/php/php.go @@ -19,8 +19,16 @@ import ( var ErrNotFound = errors.New("no php interpreter found on PATH") // DebuggerModule is the module name the php-debugger extension registers, as -// reported by `php -m`. Install verification checks for it via HasModule. -const DebuggerModule = "php-debugger" +// reported by `php -m`. Confirmed against release 0.1.0: the interpreter lists +// "php_debugger" under [PHP Modules] (and "PHP Debugger" under [Zend Modules]). +// +// The extension also exposes a *simulated* "xdebug" module for compatibility +// with tooling that probes for xdebug. We therefore verify against +// "php_debugger" specifically — checking for "xdebug" would be ambiguous, since +// a host with real xdebug (but not our debugger) would match it too. +// +// Install verification checks for it via HasModule (case-insensitive). +const DebuggerModule = "php_debugger" // Info describes a PHP interpreter. type Info struct {