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
15 changes: 13 additions & 2 deletions internal/cli/switch.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(),
})
},
}

Expand Down
19 changes: 14 additions & 5 deletions internal/installer/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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) {
Expand Down
84 changes: 84 additions & 0 deletions internal/installer/switch.go
Original file line number Diff line number Diff line change
@@ -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
}
133 changes: 133 additions & 0 deletions internal/installer/switch_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading