From b7c6f0783e60cb7466e18e2a47c3ca6b73561ef4 Mon Sep 17 00:00:00 2001 From: Carlos Granados Date: Sat, 25 Jul 2026 22:52:05 +0200 Subject: [PATCH] Switch version --- internal/cli/switch.go | 15 +++- internal/installer/install.go | 19 +++-- internal/installer/switch.go | 84 +++++++++++++++++++ internal/installer/switch_test.go | 133 ++++++++++++++++++++++++++++++ 4 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 internal/installer/switch.go create mode 100644 internal/installer/switch_test.go diff --git a/internal/cli/switch.go b/internal/cli/switch.go index f15e131..93b4458 100644 --- a/internal/cli/switch.go +++ b/internal/cli/switch.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" +) // switchOptions holds flags specific to the switch command. type switchOptions struct { @@ -19,7 +23,14 @@ func newSwitchCmd() *cobra.Command { "first.", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return errNotImplemented("switch") + return installer.Switch(cmd.Context(), installer.Options{ + Scope: platform.ScopeFromUserFlag(globalOpts.User), + PHPVersion: args[0], + ZTS: opts.ZTS, + AssumeYes: globalOpts.Yes, + Out: cmd.OutOrStdout(), + In: cmd.InOrStdin(), + }) }, } diff --git a/internal/installer/install.go b/internal/installer/install.go index 069e35c..0e51468 100644 --- a/internal/installer/install.go +++ b/internal/installer/install.go @@ -32,6 +32,11 @@ type Options struct { // may be nil, in which case prompts default to "no" unless AssumeYes). In io.Reader + // BinDir, when set, forces the active `php` to be placed in this directory + // instead of auto-choosing (replace-existing or scope bin dir). Used by + // `switch` so a newly installed variant activates where the current one lives. + BinDir string + // Client and Env are optional overrides for testing. When nil, real ones are // constructed. Client *release.Client @@ -127,7 +132,7 @@ func InstallInterpreter(ctx context.Context, opts Options) error { // Decide where the active `php` goes: replace the existing interpreter at its // location when possible, else our scope's bin dir. - linkDir, replaceExisting, err := chooseLinkDir(existing, layout) + linkDir, replaceExisting, err := chooseLinkDir(existing, layout, opts.BinDir) if err != nil { return err } @@ -283,10 +288,14 @@ func detectExisting(ctx context.Context, opts Options, root string) *php.Info { return info } -// chooseLinkDir decides where the active `php` entry goes. When an existing -// interpreter is found and its directory is writable, we replace it in place; -// otherwise we use the scope's first writable bin dir. -func chooseLinkDir(existing *php.Info, layout platform.Layout) (dir string, replace bool, err error) { +// chooseLinkDir decides where the active `php` entry goes. An explicit override +// (from `switch`) wins. Otherwise, when an existing interpreter is found and its +// directory is writable, we replace it in place; else we use the scope's first +// writable bin dir. +func chooseLinkDir(existing *php.Info, layout platform.Layout, override string) (dir string, replace bool, err error) { + if override != "" { + return override, false, nil + } if existing != nil { d := filepath.Dir(existing.Path) if platform.IsDirWritable(d) { diff --git a/internal/installer/switch.go b/internal/installer/switch.go new file mode 100644 index 0000000..047d4da --- /dev/null +++ b/internal/installer/switch.go @@ -0,0 +1,84 @@ +package installer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/php-debugger/installer/internal/manifest" + "github.com/php-debugger/installer/internal/php" + "github.com/php-debugger/installer/internal/platform" +) + +// Switch makes the given interpreter variant the active `php`. If the variant is +// already installed it just re-points the active symlink; otherwise it installs +// it first (activating at the current bin dir so all variants share one entry). +// opts.PHPVersion and opts.ZTS select the target. +func Switch(ctx context.Context, opts Options) error { + if opts.PHPVersion == "" { + return errors.New("switch requires a PHP version") + } + 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 + } + + key := platform.VersionDirName(opts.PHPVersion, opts.ZTS) + it, installed := m.Interpreter(key) + + if !installed { + opts.logf("php %s (%s) is not installed; installing it now.", + opts.PHPVersion, threading(opts.ZTS)) + io := opts + io.BinDir = m.BinDir // keep activation consistent with the current install + return InstallInterpreter(ctx, io) + } + + if m.Active() == key { + opts.logf("php %s (%s) is already active.", opts.PHPVersion, threading(opts.ZTS)) + return nil + } + + binDir := m.BinDir + if binDir == "" { + if binDir, err = platform.SelectBinDir(layout.BinCandidates); err != nil { + return err + } + } + target := filepath.Join(it.Dir, "bin", phpBinaryName(env.OS)) + if _, statErr := os.Stat(target); statErr != nil { + return fmt.Errorf("installed interpreter binary missing at %s: %w", target, statErr) + } + + // Re-point the active symlink, restoring the previous target if the new one + // fails to run. + prevTarget, _, hadPrev := platform.ReadActive(binDir, "php") + activePath, kind, err := platform.Activate(binDir, "php", target) + if err != nil { + return fmt.Errorf("activating php %s: %w", key, err) + } + if err := php.SmokeTest(ctx, activePath); err != nil { + if hadPrev { + _, _, _ = platform.Activate(binDir, "php", prevTarget) + } + return fmt.Errorf("activated php %s failed to run; reverted: %w", key, err) + } + + m.SetActive(key) + if err := m.Save(layout.ManifestPath()); err != nil { + return fmt.Errorf("saving manifest: %w", err) + } + opts.logf("Switched active php -> %s (%s) via %s. php -> %s", + opts.PHPVersion, threading(opts.ZTS), kind, activePath) + return nil +} diff --git a/internal/installer/switch_test.go b/internal/installer/switch_test.go new file mode 100644 index 0000000..d87b855 --- /dev/null +++ b/internal/installer/switch_test.go @@ -0,0 +1,133 @@ +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" +) + +// newMultiVersionServer serves a release with 8.3 and 8.4 interpreter assets, +// each a fake php reporting its own version. +func newMultiVersionServer(t *testing.T) *httptest.Server { + t.Helper() + php83 := fakePHP("8.3.7", true, "", "") + php84 := fakePHP("8.4.2", true, "", "") + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/repos/php-debugger/php-debugger/releases/latest": + fmt.Fprintf(w, `{"tag_name":"9.9.9","assets":[ + {"name":"php-php8.3-nts-linux-x86_64","browser_download_url":%q}, + {"name":"php-php8.4-nts-linux-x86_64","browser_download_url":%q} + ]}`, srv.URL+"/dl/83", srv.URL+"/dl/84") + case "/dl/83": + w.Write([]byte(php83)) + case "/dl/84": + w.Write([]byte(php84)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func TestSwitchInstallsAndFlips(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("fake php is a /bin/sh script") + } + isolatePATH(t) + home := t.TempDir() + srv := newMultiVersionServer(t) + client := release.NewClient() + client.BaseURL = srv.URL + env := linuxUserEnv(home) + + baseOpts := func() Options { + return Options{Scope: platform.User, Client: client, Env: &env, Out: &bytes.Buffer{}} + } + + root := filepath.Join(home, ".local", "share", "php-debugger") + link := filepath.Join(home, ".local", "bin", "php") + manifestPath := filepath.Join(root, "manifest.json") + + assertActive := func(wantKey, wantDir string) { + t.Helper() + tgt, err := os.Readlink(link) + if err != nil { + t.Fatalf("readlink: %v", err) + } + want := filepath.Join(root, wantDir, "bin", "php") + if tgt != want { + t.Errorf("symlink -> %q, want %q", tgt, want) + } + m, err := manifest.Load(manifestPath) + if err != nil { + t.Fatal(err) + } + if m.Active() != wantKey { + t.Errorf("manifest active = %q, want %q", m.Active(), wantKey) + } + } + + // 1. switch to 8.3 (not installed) -> installs + activates + o := baseOpts() + o.PHPVersion = "8.3" + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch 8.3: %v", err) + } + assertActive("8.3", "8.3") + + // 2. switch to 8.4 (not installed) -> installs + activates at same bin dir + o = baseOpts() + o.PHPVersion = "8.4" + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch 8.4: %v", err) + } + assertActive("8.4", "8.4") + + // both variants coexist on disk + for _, v := range []string{"8.3", "8.4"} { + if _, err := os.Stat(filepath.Join(root, v, "bin", "php")); err != nil { + t.Errorf("variant %s should still be installed: %v", v, err) + } + } + + // 3. switch back to 8.3 (installed) -> just re-points, no re-download + o = baseOpts() + o.PHPVersion = "8.3" + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch back to 8.3: %v", err) + } + assertActive("8.3", "8.3") + + // 4. switch to already-active 8.3 -> no-op message + var out bytes.Buffer + o = baseOpts() + o.PHPVersion = "8.3" + o.Out = &out + if err := Switch(context.Background(), o); err != nil { + t.Fatalf("switch to active: %v", err) + } + if !strings.Contains(out.String(), "already active") { + t.Errorf("expected 'already active' message, got: %s", out.String()) + } +} + +func TestSwitchRequiresVersion(t *testing.T) { + env := linuxUserEnv(t.TempDir()) + if err := Switch(context.Background(), Options{Scope: platform.User, Env: &env}); err == nil { + t.Error("switch with no version should error") + } +}