From c40916fbb65dc3430fa99e0e587cd384ce5852a5 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Sat, 25 Jul 2026 23:01:34 +0200 Subject: [PATCH] Update command --- internal/cli/update.go | 13 +- internal/installer/extension.go | 13 +- internal/installer/install.go | 39 +++++- internal/installer/update.go | 112 +++++++++++++++++ internal/installer/update_test.go | 196 ++++++++++++++++++++++++++++++ 5 files changed, 364 insertions(+), 9 deletions(-) create mode 100644 internal/installer/update.go create mode 100644 internal/installer/update_test.go diff --git a/internal/cli/update.go b/internal/cli/update.go index 9171162..08f52e6 100644 --- a/internal/cli/update.go +++ b/internal/cli/update.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" +) // updateOptions holds flags specific to the update command. type updateOptions struct { @@ -21,7 +25,12 @@ func newUpdateCmd() *cobra.Command { "are installed.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - return errNotImplemented("update") + return installer.Update(cmd.Context(), installer.Options{ + Scope: platform.ScopeFromUserFlag(globalOpts.User), + AssumeYes: globalOpts.Yes, + Out: cmd.OutOrStdout(), + In: cmd.InOrStdin(), + }, opts.Interpreter, opts.Extension) }, } diff --git a/internal/installer/extension.go b/internal/installer/extension.go index eeff40c..e5a6b6c 100644 --- a/internal/installer/extension.go +++ b/internal/installer/extension.go @@ -49,17 +49,20 @@ func InstallExtension(ctx context.Context, opts Options) error { return fmt.Errorf("php at %s reports no extension_dir; cannot install the extension", path) } - // Nothing to do if the debugger is already present (e.g. our own interpreter). - if has, _ := php.HasModule(ctx, path, php.DebuggerModule); has { - opts.logf("%s already has the %s module; nothing to do.", path, php.DebuggerModule) - return nil + // Nothing to do if the debugger is already present (e.g. our own + // interpreter), unless forced (as by `update`). + if !opts.Force { + if has, _ := php.HasModule(ctx, path, php.DebuggerModule); has { + opts.logf("%s already has the %s module; nothing to do.", path, php.DebuggerModule) + return nil + } } client := opts.Client if client == nil { client = release.NewClient() } - rel, err := client.LatestRelease(ctx) + rel, err := opts.latestRelease(ctx, client) if err != nil { return err } diff --git a/internal/installer/install.go b/internal/installer/install.go index 0e51468..59d9365 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -37,14 +37,30 @@ type Options struct { // `switch` so a newly installed variant activates where the current one lives. BinDir string + // Force skips "nothing to do" short-circuits (used by `update` to reinstall + // even when the debugger is already present). + Force bool + // Client and Env are optional overrides for testing. When nil, real ones are // constructed. Client *release.Client Env *platform.Env + // preloadedRelease, when set, is used instead of fetching the latest release + // (so `update` can fetch once to compare versions, then reuse it). + preloadedRelease *release.Release + now func() time.Time // optional clock override for tests } +// latestRelease returns the preloaded release if set, otherwise fetches it. +func (o Options) latestRelease(ctx context.Context, client *release.Client) (*release.Release, error) { + if o.preloadedRelease != nil { + return o.preloadedRelease, nil + } + return client.LatestRelease(ctx) +} + func (o Options) logf(format string, args ...any) { if o.Out != nil { fmt.Fprintf(o.Out, format+"\n", args...) @@ -103,7 +119,7 @@ func InstallInterpreter(ctx context.Context, opts Options) error { if client == nil { client = release.NewClient() } - rel, err := client.LatestRelease(ctx) + rel, err := opts.latestRelease(ctx, client) if err != nil { return err } @@ -171,10 +187,24 @@ func InstallInterpreter(ctx context.Context, opts Options) error { // --- place the binary into the versioned directory --- versionDir := layout.VersionDir(series, opts.ZTS) binTarget := filepath.Join(versionDir, "bin", phpBinaryName(p.OS)) + // If the version dir already exists (an update/reinstall), preserve the old + // binary so a failed install can be rolled back to a working state; else the + // whole fresh dir is removed on rollback. + versionExisted := isDir(versionDir) + if versionExisted { + if err := registerFileRestore(binTarget, rb, 0o755); err != nil { + return err + } + } if err := installFile(dlPath, binTarget); err != nil { + if !versionExisted { + os.RemoveAll(versionDir) + } return fmt.Errorf("installing interpreter binary: %w", err) } - rb.add(func() error { return os.RemoveAll(versionDir) }) + if !versionExisted { + rb.add(func() error { return os.RemoveAll(versionDir) }) + } // --- copy the existing interpreter's ini config into the new one --- var configFiles []string @@ -327,6 +357,11 @@ func maybeWarnManaged(opts Options, existing *php.Info) { } } +func isDir(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.IsDir() +} + func threading(zts bool) string { if zts { return "zts" diff --git a/internal/installer/update.go b/internal/installer/update.go new file mode 100644 index 0000000..58f9f52 --- /dev/null +++ b/internal/installer/update.go @@ -0,0 +1,112 @@ +package installer + +import ( + "context" + "errors" + "fmt" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// Update reinstalls the active interpreter and/or the extension against the +// latest release. With neither wantInterp nor wantExt set it updates whatever is +// installed, erroring if both are present (ambiguous). Each target is skipped if +// already on the latest release. +func Update(ctx context.Context, opts Options, wantInterp, wantExt 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 := m.Active() != "" + hasExt := m.Extension != nil + + if !wantInterp && !wantExt { + switch { + 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 update") + } + } + if wantInterp && !hasInterp { + return errors.New("no interpreter installed to update") + } + if wantExt && !hasExt { + return errors.New("no extension installed to update") + } + + // Fetch the latest release once and reuse it for the install(s). + client := opts.Client + if client == nil { + client = release.NewClient() + } + rel, err := client.LatestRelease(ctx) + if err != nil { + return err + } + + if wantInterp { + if err := updateInterpreter(ctx, opts, m, rel, client); err != nil { + return err + } + } + if wantExt { + if err := updateExtension(ctx, opts, m, rel, client); err != nil { + return err + } + } + return nil +} + +func updateInterpreter(ctx context.Context, opts Options, m *manifest.Manifest, rel *release.Release, client *release.Client) error { + key := m.Active() + it, _ := m.Interpreter(key) + if it.ReleaseTag == rel.TagName { + opts.logf("Interpreter php %s is already up to date (release %s).", it.Series, rel.TagName) + return nil + } + opts.logf("Updating interpreter php %s (%s): %s -> %s ...", + it.Series, threading(it.ZTS), it.ReleaseTag, rel.TagName) + + io := opts + io.PHPVersion = it.Series + io.ZTS = it.ZTS + io.BinDir = m.BinDir + io.Client = client + io.preloadedRelease = rel + return InstallInterpreter(ctx, io) +} + +func updateExtension(ctx context.Context, opts Options, m *manifest.Manifest, rel *release.Release, client *release.Client) error { + ext := m.Extension + if ext.ReleaseTag == rel.TagName { + opts.logf("Extension for php %s is already up to date (release %s).", ext.Series, rel.TagName) + return nil + } + opts.logf("Updating extension for php %s: %s -> %s ...", ext.Series, ext.ReleaseTag, rel.TagName) + + io := opts + io.Force = true + io.Client = client + io.preloadedRelease = rel + if err := InstallExtension(ctx, io); err != nil { + return fmt.Errorf("updating extension: %w", err) + } + return nil +} diff --git a/internal/installer/update_test.go b/internal/installer/update_test.go new file mode 100644 index 0000000..b9df95c --- /dev/null +++ b/internal/installer/update_test.go @@ -0,0 +1,196 @@ +package installer + +import ( + "bytes" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/platform" + "github.com/php-debugger/installer/internal/release" +) + +// mutableServer serves a release whose tag and interpreter bytes can be changed +// between requests, to simulate a new release becoming available. +type mutableServer struct { + mu sync.Mutex + tag string + script string + URL string +} + +func newMutableServer(t *testing.T, tag, script string) *mutableServer { + t.Helper() + ms := &mutableServer{tag: tag, script: script} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ms.mu.Lock() + tag, script := ms.tag, ms.script + ms.mu.Unlock() + switch { + case strings.HasSuffix(r.URL.Path, "/releases/latest"): + fmt.Fprintf(w, `{"tag_name":%q,"assets":[ + {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q} + ]}`, tag, ms.URL+"/dl/php") + case r.URL.Path == "/dl/php": + w.Write([]byte(script)) + default: + http.NotFound(w, r) + } + })) + ms.URL = srv.URL + t.Cleanup(srv.Close) + return ms +} + +func (ms *mutableServer) set(tag, script string) { + ms.mu.Lock() + ms.tag, ms.script = tag, script + ms.mu.Unlock() +} + +func installBaseInterpreter(t *testing.T, home string, ms *mutableServer) (Options, string) { + t.Helper() + client := release.NewClient() + client.BaseURL = ms.URL + env := linuxUserEnv(home) + opts := Options{Scope: platform.User, Client: client, Env: &env, Out: &bytes.Buffer{}, PHPVersion: "8.3"} + if err := InstallInterpreter(context.Background(), opts); err != nil { + t.Fatalf("base install: %v", err) + } + return opts, filepath.Join(home, ".local", "share", "php-debugger", "manifest.json") +} + +func TestUpdateInterpreter(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, "", "")) + opts, manifestPath := installBaseInterpreter(t, home, ms) + + // A newer release appears. + ms.set("2.0.0", fakePHP("8.3.9", true, "", "")) + + var out bytes.Buffer + uo := opts + uo.Out = &out + uo.PHPVersion = "" // update figures out the target from the manifest + if err := Update(context.Background(), uo, false, false); err != nil { + t.Fatalf("Update: %v\n%s", err, out.String()) + } + + m, _ := manifest.Load(manifestPath) + it, _ := m.Interpreter("8.3") + if it.ReleaseTag != "2.0.0" { + t.Errorf("ReleaseTag = %q, want 2.0.0", it.ReleaseTag) + } + if it.PHPVersion != "8.3.9" { + t.Errorf("PHPVersion = %q, want 8.3.9", it.PHPVersion) + } +} + +func TestUpdateAlreadyLatest(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, "", "")) + opts, _ := installBaseInterpreter(t, home, ms) + + var out bytes.Buffer + uo := opts + uo.Out = &out + if err := Update(context.Background(), uo, false, false); err != nil { + t.Fatalf("Update: %v", err) + } + if !strings.Contains(out.String(), "up to date") { + t.Errorf("expected 'up to date', got: %s", out.String()) + } +} + +func TestUpdateRollbackKeepsWorkingInstall(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, "", "")) + opts, manifestPath := installBaseInterpreter(t, home, ms) + + // New release is broken: the interpreter lacks the debugger module. + ms.set("2.0.0", fakePHP("8.3.9", false, "", "")) + + var out bytes.Buffer + uo := opts + uo.Out = &out + err := Update(context.Background(), uo, false, false) + if err == nil { + t.Fatalf("expected update to fail on broken release\n%s", out.String()) + } + + // Manifest must still point at the old, working release. + m, _ := manifest.Load(manifestPath) + it, _ := m.Interpreter("8.3") + if it.ReleaseTag != "1.0.0" || it.PHPVersion != "8.3.7" { + t.Errorf("manifest changed after failed update: %+v", it) + } + // The active php must still run and have the debugger (old binary restored). + link := filepath.Join(home, ".local", "bin", "php") + has, err := phpHasDebugger(link) + if err != nil { + t.Fatalf("running restored php: %v", err) + } + if !has { + t.Error("restored interpreter should still report the debugger module") + } +} + +func TestUpdateNothingInstalled(t *testing.T) { + env := linuxUserEnv(t.TempDir()) + err := Update(context.Background(), Options{Scope: platform.User, Env: &env}, false, false) + if err == nil || !strings.Contains(err.Error(), "nothing installed") { + t.Errorf("expected 'nothing installed' error, got: %v", err) + } +} + +func TestUpdateAmbiguous(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + ms := newMutableServer(t, "1.0.0", fakePHP("8.3.7", true, "", "")) + _, manifestPath := installBaseInterpreter(t, home, ms) + + // Also record an extension so both are present. + m, _ := manifest.Load(manifestPath) + m.SetExtension(manifest.Extension{Series: "8.3", ReleaseTag: "1.0.0"}) + if err := m.Save(manifestPath); err != nil { + t.Fatal(err) + } + + env := linuxUserEnv(home) + err := Update(context.Background(), Options{Scope: platform.User, Env: &env}, false, false) + if err == nil || !strings.Contains(err.Error(), "specify") { + t.Errorf("expected ambiguity error, got: %v", err) + } +} + +// phpHasDebugger runs `path -m` and reports whether php_debugger is listed. +func phpHasDebugger(path string) (bool, error) { + out, err := exec.Command(path, "-m").Output() + if err != nil { + return false, err + } + return strings.Contains(string(out), "php_debugger"), nil +}