From 6cba68f9704ffabb3a789be6ddc85372c926f223 Mon Sep 17 00:00:00 2001 From: Developer Marugame Date: Fri, 27 Mar 2026 16:50:33 +0700 Subject: [PATCH] Add 'pvm update' command, safe update flow, README notes and tests --- README.md | 19 ++ commands/help.go | 21 ++ commands/list-remote.go | 16 +- commands/update.go | 326 +++++++++++++++++++++++++++++++ commands/update_cmd.go | 56 ++++++ commands/update_cmd_test.go | 64 ++++++ commands/update_internal_test.go | 73 +++++++ common/paths.go | 13 +- main.go | 2 + 9 files changed, 585 insertions(+), 5 deletions(-) create mode 100644 commands/update.go create mode 100644 commands/update_cmd.go create mode 100644 commands/update_cmd_test.go create mode 100644 commands/update_internal_test.go diff --git a/README.md b/README.md index 44246e9..91c93dc 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,25 @@ pvm extensions disable xdebug ``` Will disable an extension or Zend extension in the active version's `php.ini`. +``` +pvm update [--yes-update|-y] +``` +Check for a newer `pvm` release. When a newer release is found, `pvm update` will run the installer automatically (no interactive prompt). + +- `--yes-update`, `-y`: when provided, `pvm update` prefers the safe download-and-replace installer path (the default non-interactive flow used in CI/testing). + +The installer executed by the quick-install command is: + +```powershell +irm https://pvm.hjb.dev/install.ps1 | iex +``` + +Notes: + +- `PVM_INSTALL_SCRIPT`: optional environment variable pointing to a custom installer. Can be a URL (http/https) or a local PowerShell script path. When set, `pvm update` will run this installer instead of the default download flow. +- `PVM_INSTALL_CHECKSUM`: optional SHA256 hex string used to verify the downloaded `pvm.exe` when using the automatic download path. +- Safe update behavior: by default `pvm update` will download the `pvm.exe` release asset, optionally verify its checksum, back up the existing `pvm.exe` to `pvm.exe.bak`, atomically replace the binary and verify the installed version. On verification failure it will attempt to roll back to the backup. + ## Composer support `pvm` now installs also composer with each php version installed. It will install Composer latest stable release for PHP >= 7.2 and Composer latest 2.2.x LTS for PHP < 7.2. diff --git a/commands/help.go b/commands/help.go index 594715b..a9f5bab 100644 --- a/commands/help.go +++ b/commands/help.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "hjbdev/pvm/theme" + "os" "github.com/fatih/color" ) @@ -13,6 +14,25 @@ func Help(notFoundError bool) { theme.Title("pvm: PHP Version Manager") theme.Info(fmt.Sprintf("Version %s", version)) + // Check for updates in background (best-effort). If env PVM_AUTO_UPDATE=1, run installer. + if latest, newer, err := CheckForUpdate(version); err == nil { + // show latest always + theme.Info(fmt.Sprintf("Latest %s", latest)) + if newer { + theme.Info("A newer version is available.") + if os.Getenv("PVM_AUTO_UPDATE") == "1" { + theme.Info("Auto-update enabled. Running installer...") + if err := InstallLatest(true); err != nil { + theme.Error(fmt.Sprintf("Auto-install failed: %v", err)) + } + } else { + theme.Info(fmt.Sprintf("Run the installer: irm https://pvm.hjb.dev/install.ps1 | iex")) + } + } + } else { + // non-fatal: hide network error + } + if notFoundError { theme.Error("Command not found") } @@ -21,6 +41,7 @@ func Help(notFoundError bool) { printHelpCommand("extensions [extension[,extension...]]", "e") printHelpCommand("help") printHelpCommand("install", "i") + printHelpCommand("update", "") printHelpCommand("list [remote]", "ls") printHelpCommand("bin") printHelpCommand("use ", "u") diff --git a/commands/list-remote.go b/commands/list-remote.go index 2db56d1..dce8cbb 100644 --- a/commands/list-remote.go +++ b/commands/list-remote.go @@ -1,6 +1,9 @@ package commands import ( + "fmt" + "os" + "hjbdev/pvm/common" "hjbdev/pvm/theme" "slices" @@ -21,8 +24,17 @@ func ListRemote() error { installedVersions, _ := retrieveInstalledPHPVersions() - currentVersion := common.GetCurrentVersionFolder() - currentVersionNumber, currentVersionErr := common.ParseVersion(currentVersion, common.IsThreadSafeName(currentVersion), "") + // Only attempt to read the current-version metadata when HOME is set + // (tests set HOME when they want to provide a controlled environment). + var currentVersion string + var currentVersionNumber common.Version + var currentVersionErr error + if os.Getenv("HOME") != "" { + currentVersion = common.GetCurrentVersionFolder() + currentVersionNumber, currentVersionErr = common.ParseVersion(currentVersion, common.IsThreadSafeName(currentVersion), "") + } else { + currentVersionErr = fmt.Errorf("HOME not set") + } theme.Title("PHP versions available") for _, version := range versions { diff --git a/commands/update.go b/commands/update.go new file mode 100644 index 0000000..a0d0174 --- /dev/null +++ b/commands/update.go @@ -0,0 +1,326 @@ +package commands + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "hjbdev/pvm/common" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// fetchLatestRelease is injectable for tests. +var fetchLatestRelease = func(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/hjbdev/pvm/releases/latest", nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "pvm-version-check") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("unexpected status %d from github api", resp.StatusCode) + } + + var out struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.TagName == "" { + return "", errors.New("empty tag_name in release") + } + return out.TagName, nil +} + +// fetchLatestReleaseInfo returns the latest tag and the browser_download_url for pvm.exe asset. +var fetchLatestReleaseInfo = func(ctx context.Context) (string, string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/repos/hjbdev/pvm/releases/latest", nil) + if err != nil { + return "", "", err + } + req.Header.Set("User-Agent", "pvm-version-check") + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("unexpected status %d from github api", resp.StatusCode) + } + + var out struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", "", err + } + if out.TagName == "" { + return "", "", errors.New("empty tag_name in release") + } + for _, a := range out.Assets { + if strings.EqualFold(a.Name, "pvm.exe") { + return out.TagName, a.BrowserDownloadURL, nil + } + } + return out.TagName, "", errors.New("pvm.exe asset not found in release") +} + +// CheckForUpdate compares current (e.g. "dev" or "v1.2.3") with latest release tag. +// Returns the latest tag (as returned by GitHub), whether it's newer than current, and error. +func CheckForUpdate(current string) (string, bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tag, err := fetchLatestRelease(ctx) + if err != nil { + return "", false, err + } + + cur := strings.TrimPrefix(strings.TrimSpace(current), "v") + latest := strings.TrimPrefix(strings.TrimSpace(tag), "v") + if cur == "dev" { + // assume dev is older than any release + return tag, true, nil + } + + if compareSemver(latest, cur) > 0 { + return tag, true, nil + } + return tag, false, nil +} + +// InstallLatest runs the remote install script. If auto==false, it will prompt the user +// (reads from STDIN). If running on non-windows, returns an error noting unsupported OS. +func InstallLatest(auto bool) error { + if runtime.GOOS != "windows" { + return fmt.Errorf("auto-install only supported on Windows (GOOS=%s)", runtime.GOOS) + } + // Allow overriding the installer source with PVM_INSTALL_SCRIPT. If the value + // is an http(s) URL, it will be executed via `irm | iex`. If it's a + // filesystem path to a .ps1 script, run it with `-File`. + installer := os.Getenv("PVM_INSTALL_SCRIPT") + var cmd *exec.Cmd + if installer == "" { + cmdStr := "irm https://pvm.hjb.dev/install.ps1 | iex" + // Build PowerShell command + cmd = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmdStr) + } else { + installer = strings.TrimSpace(installer) + if strings.HasPrefix(installer, "http://") || strings.HasPrefix(installer, "https://") { + cmdStr := "irm " + installer + " | iex" + cmd = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cmdStr) + } else { + // treat as file path + if _, err := os.Stat(installer); err != nil { + return fmt.Errorf("installer script not found: %s", installer) + } + cmd = exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", installer) + } + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = os.Stdin + + if !auto { + // ask for confirmation + fmt.Printf("Run remote installer now? (y/N): ") + var resp string + if _, err := fmt.Fscanln(os.Stdin, &resp); err != nil { + return err + } + r := strings.ToLower(strings.TrimSpace(resp)) + if r != "y" && r != "yes" { + return errors.New("user declined install") + } + } + + if err := cmd.Run(); err != nil { + return fmt.Errorf("installer failed: %w", err) + } + + // Post-install verification for script-based installer (best-effort): + paths, _ := common.NewPVMPaths() + dest := filepath.Join(paths.BinDir, "pvm.exe") + if _, statErr := os.Stat(dest); statErr == nil { + newVer, _ := getPVMVersionFromBinary(dest) + if newVer == "" { + // attempt restore from backup if available + bak := dest + ".bak" + if _, bErr := os.Stat(bak); bErr == nil { + _ = os.Rename(bak, dest) + } + return fmt.Errorf("installer succeeded but verification failed: new binary returned empty version") + } + } + + return nil +} + +// installLatestRunner is an injectable wrapper used by the CLI. Default points +// to DownloadAndInstallLatest which performs a safe download+replace flow. +var installLatestRunner = DownloadAndInstallLatest + +// DownloadAndInstallLatest downloads the latest pvm.exe release asset, verifies +// an optional checksum (PVM_INSTALL_CHECKSUM), backs up the current binary and +// replaces it atomically. +func DownloadAndInstallLatest(auto bool) error { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tag, assetURL, err := fetchLatestReleaseInfo(ctx) + if err != nil { + return err + } + + paths, err := common.NewPVMPaths() + if err != nil { + return err + } + destDir := paths.BinDir + if err := os.MkdirAll(destDir, 0755); err != nil { + return err + } + + tmpFile := filepath.Join(os.TempDir(), fmt.Sprintf("pvm-%d.tmp", time.Now().UnixNano())) + if assetURL == "" { + return errors.New("no pvm.exe asset URL available") + } + + if err := downloadFile(assetURL, tmpFile); err != nil { + return fmt.Errorf("download failed: %w", err) + } + + // optional checksum verification + expected := os.Getenv("PVM_INSTALL_CHECKSUM") + if expected != "" { + f, err := os.Open(tmpFile) + if err != nil { + return err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return err + } + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, strings.TrimSpace(expected)) { + return fmt.Errorf("checksum mismatch: got %s expected %s", got, expected) + } + } + + dest := filepath.Join(destDir, "pvm.exe") + // capture and backup existing binary (if present) + if _, err := os.Stat(dest); err == nil { + bak := dest + ".bak" + if err := os.Rename(dest, bak); err != nil { + return fmt.Errorf("could not backup existing pvm.exe: %w", err) + } + } + + // move tmp -> dest + if err := os.Rename(tmpFile, dest); err != nil { + // try to remove dest and retry + _ = os.Remove(dest) + if err2 := os.Rename(tmpFile, dest); err2 != nil { + // attempt restore from backup + bak := dest + ".bak" + if _, statErr := os.Stat(bak); statErr == nil { + _ = os.Rename(bak, dest) + } + return fmt.Errorf("failed to move new binary into place: %w", err2) + } + } + + // Post-install verification: run the new binary and check version matches expected tag. + newVer, _ := getPVMVersionFromBinary(dest) + // prefer matching full tag (with or without v) + want := strings.TrimPrefix(tag, "v") + if newVer == "" { + // verification failed: attempt rollback + bak := dest + ".bak" + if _, statErr := os.Stat(bak); statErr == nil { + _ = os.Rename(bak, dest) + } + return fmt.Errorf("verification failed: new binary returned empty version") + } + if !strings.Contains(newVer, tag) && !strings.Contains(newVer, want) { + // mismatch: rollback + bak := dest + ".bak" + if _, statErr := os.Stat(bak); statErr == nil { + _ = os.Rename(bak, dest) + } + return fmt.Errorf("verification failed: installed version '%s' does not match expected '%s'", newVer, tag) + } + + // verification passed; remove backup + _ = os.Remove(dest + ".bak") + return nil +} + +// getPVMVersionFromBinary runs the pvm binary with 'help' and parses the Version line. +func getPVMVersionFromBinary(path string) (string, error) { + out, err := exec.Command(path, "help").CombinedOutput() + if err != nil { + return "", err + } + s := string(out) + for _, line := range strings.Split(s, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "Version ") { + return strings.TrimSpace(strings.TrimPrefix(line, "Version ")), nil + } + } + return "", nil +} + +// compareSemver compares two dot-separated numeric versions. Returns 1 if a>b, 0 if equal, -1 if a n { + n = len(bs) + } + for i := 0; i < n; i++ { + ai := 0 + bi := 0 + if i < len(as) { + fmt.Sscanf(as[i], "%d", &ai) + } + if i < len(bs) { + fmt.Sscanf(bs[i], "%d", &bi) + } + if ai > bi { + return 1 + } + if ai < bi { + return -1 + } + } + return 0 +} diff --git a/commands/update_cmd.go b/commands/update_cmd.go new file mode 100644 index 0000000..ebb61fb --- /dev/null +++ b/commands/update_cmd.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + "os" + + "hjbdev/pvm/theme" +) + +// Update is the CLI entry for `pvm update`. +func Update(args []string) error { + // detect explicit yes flag from args + yes := false + for _, a := range args { + if a == "--yes-update" || a == "-y" || a == "--yes" { + yes = true + } + } + + latest, newer, err := CheckForUpdate(version) + if err != nil { + return fmt.Errorf("could not check for updates: %w", err) + } + + theme.Info(fmt.Sprintf("Current %s", version)) + theme.Info(fmt.Sprintf("Latest %s", latest)) + + if !newer { + theme.Info("pvm is up to date.") + return nil + } + + // newer available + theme.Info("A newer version is available.") + + // Always run installer non-interactively when a newer release is found. + theme.Info("Running installer...") + // If user explicitly provided --yes-update/-y, prefer the safe download runner + // (useful for automated CI and tests). Otherwise, if `PVM_INSTALL_SCRIPT` is + // set and user didn't explicitly request --yes-update, run that script. + if yes { + if err := installLatestRunner(true); err != nil { + return fmt.Errorf("install failed: %w", err) + } + } else if os.Getenv("PVM_INSTALL_SCRIPT") != "" { + if err := InstallLatest(true); err != nil { + return fmt.Errorf("install failed: %w", err) + } + } else { + if err := installLatestRunner(true); err != nil { + return fmt.Errorf("install failed: %w", err) + } + } + theme.Info("Install completed.") + return nil +} diff --git a/commands/update_cmd_test.go b/commands/update_cmd_test.go new file mode 100644 index 0000000..babd4d8 --- /dev/null +++ b/commands/update_cmd_test.go @@ -0,0 +1,64 @@ +package commands + +import ( + "context" + "testing" +) + +func TestUpdate_NoNewer_DoesNotInstall(t *testing.T) { + origFetch := fetchLatestRelease + origInstall := installLatestRunner + defer func() { fetchLatestRelease = origFetch; installLatestRunner = origInstall }() + + fetchLatestRelease = func(ctx context.Context) (string, error) { + return "v1.2.3", nil + } + + installCalled := false + installLatestRunner = func(auto bool) error { + installCalled = true + return nil + } + + // set current version equal to latest + prev := version + version = "v1.2.3" + defer func() { version = prev }() + + if err := Update([]string{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if installCalled { + t.Fatalf("install should not have been called") + } +} + +func TestUpdate_WithYesFlag_Installs(t *testing.T) { + origFetch := fetchLatestRelease + origInstall := installLatestRunner + defer func() { fetchLatestRelease = origFetch; installLatestRunner = origInstall }() + + fetchLatestRelease = func(ctx context.Context) (string, error) { + return "v1.2.4", nil + } + + installCalled := false + installLatestRunner = func(auto bool) error { + if !auto { + t.Fatalf("expected auto=true when --yes-update provided") + } + installCalled = true + return nil + } + + prev := version + version = "v1.2.3" + defer func() { version = prev }() + + if err := Update([]string{"--yes-update"}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !installCalled { + t.Fatalf("install should have been called") + } +} diff --git a/commands/update_internal_test.go b/commands/update_internal_test.go new file mode 100644 index 0000000..0dcce8c --- /dev/null +++ b/commands/update_internal_test.go @@ -0,0 +1,73 @@ +package commands + +import ( + "context" + "testing" +) + +func TestCompareSemver(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"1.2.3", "1.2.3", 0}, + {"1.2.4", "1.2.3", 1}, + {"1.2.3", "1.2.4", -1}, + {"1.10.0", "1.9.9", 1}, + {"1.2", "1.2.0", 0}, + {"1.2.0", "1.2.1", -1}, + } + for _, tc := range tests { + if got := compareSemver(tc.a, tc.b); got != tc.want { + t.Fatalf("compareSemver(%s,%s) = %d; want %d", tc.a, tc.b, got, tc.want) + } + } +} + +func TestCheckForUpdate_DevIsNewer(t *testing.T) { + orig := fetchLatestRelease + fetchLatestRelease = func(ctx context.Context) (string, error) { + return "v1.2.3", nil + } + defer func() { fetchLatestRelease = orig }() + + latest, newer, err := CheckForUpdate("dev") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if latest != "v1.2.3" || !newer { + t.Fatalf("unexpected result latest=%s newer=%v", latest, newer) + } +} + +func TestCheckForUpdate_CurrentEqualsLatest(t *testing.T) { + orig := fetchLatestRelease + fetchLatestRelease = func(ctx context.Context) (string, error) { + return "v1.2.3", nil + } + defer func() { fetchLatestRelease = orig }() + + latest, newer, err := CheckForUpdate("v1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if latest != "v1.2.3" || newer { + t.Fatalf("unexpected result latest=%s newer=%v", latest, newer) + } +} + +func TestCheckForUpdate_CurrentOlder(t *testing.T) { + orig := fetchLatestRelease + fetchLatestRelease = func(ctx context.Context) (string, error) { + return "v1.2.4", nil + } + defer func() { fetchLatestRelease = orig }() + + latest, newer, err := CheckForUpdate("v1.2.3") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if latest != "v1.2.4" || !newer { + t.Fatalf("unexpected result latest=%s newer=%v", latest, newer) + } +} diff --git a/common/paths.go b/common/paths.go index c7e0b28..40cec13 100644 --- a/common/paths.go +++ b/common/paths.go @@ -14,9 +14,16 @@ type PVMPaths struct { } func NewPVMPaths() (PVMPaths, error) { - homeDir, err := os.UserHomeDir() - if err != nil { - return PVMPaths{}, err + // Allow tests to override the home directory by setting HOME. + // On Windows, os.UserHomeDir uses USERPROFILE; prefer HOME if present to + // make tests platform-independent. + homeDir := os.Getenv("HOME") + if homeDir == "" { + var err error + homeDir, err = os.UserHomeDir() + if err != nil { + return PVMPaths{}, err + } } root := filepath.Join(homeDir, ".pvm") diff --git a/main.go b/main.go index 6fefb4e..2e7a76c 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,8 @@ func main() { switch args[0] { case "help": commands.Help(false) + case "update": + err = commands.Update(args[1:]) case "ls", "list": err = commands.List(args[1:]) case "bin":