From d85833df1fe7ac4897b6e874e1accd9379a7303a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 16:43:03 -0500 Subject: [PATCH 01/16] Add tool abstraction layer with :download / :path / :install / :update pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `internal/tools/` package with a `Tool` registry that standardizes how binaries are stored (internal/bin/) and exposed to PATH (bin/) via shims or symlinks. Each tool follows a consistent command pattern: - :download — fetch binary to private storage - :path — expose/unexpose from user PATH (--remove flag) - :install — orchestrator: :download + :path - :update — check for newer version, re-download, re-expose if on PATH Key changes: - Move mago binary to ~/.pv/internal/bin/, symlinked from ~/.pv/bin/ - Move composer.phar to ~/.pv/internal/bin/ - Move shim logic from phpenv/shim.go to tools/shims.go - Add tools.Expose/Unexpose/ExposeAll for managing PATH entries - Refactor :install commands to delegate to :download (no duplicated logic) - Add :update commands for all tools (php, mago, composer, colima) - Refactor `pv update` to orchestrate per-tool :update commands - Add pv self-update with syscall.Exec re-exec for consistent tool updates - Migrate daemon commands to colon-namespace (daemon:enable, daemon:disable) - Update CLAUDE.md with tool command pattern rules --- CLAUDE.md | 30 ++++- cmd/colima_download.go | 29 +++++ cmd/colima_install.go | 20 +-- cmd/colima_update.go | 34 +++++ cmd/composer_download.go | 50 ++++++++ cmd/composer_install.go | 41 ++---- cmd/composer_path.go | 38 ++++++ cmd/composer_update.go | 54 ++++++++ cmd/daemon.go | 22 ++-- cmd/install.go | 14 ++- cmd/mago_download.go | 50 ++++++++ cmd/mago_install.go | 41 ++---- cmd/mago_path.go | 38 ++++++ cmd/mago_update.go | 72 +++++++++++ cmd/php_download.go | 40 ++++++ cmd/php_install.go | 19 +-- cmd/php_path.go | 45 +++++++ cmd/php_update.go | 54 ++++++++ cmd/setup.go | 7 +- cmd/update.go | 136 ++++++++++---------- internal/binaries/install.go | 7 +- internal/config/paths.go | 6 +- internal/config/paths_test.go | 14 ++- internal/phpenv/phpenv.go | 13 +- internal/phpenv/shim.go | 168 +++---------------------- internal/selfupdate/selfupdate.go | 161 ++++++++++++++++++++++++ internal/tools/shims.go | 163 ++++++++++++++++++++++++ internal/tools/tool.go | 166 ++++++++++++++++++++++++ internal/tools/tool_test.go | 203 ++++++++++++++++++++++++++++++ 29 files changed, 1398 insertions(+), 337 deletions(-) create mode 100644 cmd/colima_download.go create mode 100644 cmd/colima_update.go create mode 100644 cmd/composer_download.go create mode 100644 cmd/composer_path.go create mode 100644 cmd/composer_update.go create mode 100644 cmd/mago_download.go create mode 100644 cmd/mago_path.go create mode 100644 cmd/mago_update.go create mode 100644 cmd/php_download.go create mode 100644 cmd/php_path.go create mode 100644 cmd/php_update.go create mode 100644 internal/selfupdate/selfupdate.go create mode 100644 internal/tools/shims.go create mode 100644 internal/tools/tool.go create mode 100644 internal/tools/tool_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 2c178d3..4fb02c1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,12 +33,15 @@ internal/ settings.go # TLD + GlobalPHP settings registry/ # project registry (JSON in ~/.pv/data/registry.json) registry.go # Project{Name,Path,Type,PHP}, Registry with CRUD + GroupByPHP + tools/ # tool abstraction layer + tool.go # Tool struct, registry, Expose/Unexpose/IsExposed/ExposeAll + shims.go # PHP + Composer shim scripts phpenv/ # PHP version management phpenv.go # InstalledVersions, IsInstalled, SetGlobal, Remove install.go # Download FrankenPHP from prvious/pv releases + PHP CLI from static-php.dev resolve.go # ResolveVersion: .pv-php → composer.json → global default available.go # AvailableVersions from GitHub releases - shim.go # WriteShims: creates ~/.pv/bin/php shim script + shim.go # WriteShims (delegates to tools.ExposeAll) caddy/ # Caddyfile generation (multi-version aware) caddy.go # GenerateSiteConfig(project, globalPHP), GenerateAllConfigs, GenerateVersionCaddyfile server/ # process management @@ -54,7 +57,15 @@ internal/ ``` ~/.pv/ -├── bin/ # symlinks to active global version + Mago, Composer +├── bin/ # User PATH — shims and symlinks ONLY +│ ├── php # Shim (version resolution) +│ ├── composer # Shim (wraps PHAR with PHP) +│ ├── frankenphp → ../php/{ver}/frankenphp # Symlink +│ └── mago → ../internal/bin/mago # Symlink +├── internal/bin/ # pv's private toolbox — never on PATH +│ ├── colima # Container runtime +│ ├── mago # Real binary +│ └── composer.phar # Real PHAR ├── config/ # Caddyfiles + settings.json │ ├── Caddyfile # main process │ ├── php-8.3.Caddyfile # secondary process (if needed) @@ -80,6 +91,21 @@ internal/ - **Unit tests** (`go test ./...`): Run locally. Use `t.Setenv("HOME", t.TempDir())` for filesystem isolation. Fake binaries (bash scripts) can stand in for real PHP when testing shims. - **E2E tests** (`.github/workflows/e2e.yml` + `scripts/e2e/`): Run on GitHub Actions (macOS runner) to simulate real end-user flows. These tests use real PHP, real Composer, real FrankenPHP — things we can't easily run locally. **When your feature involves real binary execution, network calls, DNS, HTTPS, or anything that needs a full `pv install` environment, add an e2e script in `scripts/e2e/` and wire it into the workflow.** Each script sources `scripts/e2e/helpers.sh` for `assert_contains`, `assert_fails`, `curl_site`, etc. The workflow phases run sequentially: install → verify → fixtures → link → start → curl → shim → composer → errors → stop → lifecycle → update → verify-final. +## Tool command pattern (:download / :path / :install) + +Every managed tool follows a strict three-command pattern: + +- **`:download`** — Fetches the binary to private storage (`internal/bin/` or `php/{ver}/`). Contains the actual download logic. +- **`:path`** — Exposes the binary to the user's PATH (`~/.pv/bin/`) via shim or symlink. Supports `--remove` to unexpose. +- **`:install`** — Orchestrator only. Calls `:download` RunE, then `tools.Expose()`. **Never duplicates download logic.** + +Rules: +1. `:install` must always delegate to `:download` — never inline download logic. +2. Download logic lives in `internal/binaries/` or `internal/phpenv/`, not in `cmd/`. +3. Exposure logic lives in `internal/tools/` — shims and symlinks are managed by `tools.Expose()` / `tools.Unexpose()`. +4. The `internal/tools/` package defines each tool's `ExposureType` (None, Symlink, Shim) and `AutoExpose` flag. +5. `tools.ExposeAll()` is the single entry point for batch-exposing all auto-exposed tools (called by `pv install` and `pv setup`). + ## Key patterns - **Test isolation**: Tests use `t.Setenv("HOME", t.TempDir())` so filesystem ops go to a temp dir. diff --git a/cmd/colima_download.go b/cmd/colima_download.go new file mode 100644 index 0000000..2d0a79d --- /dev/null +++ b/cmd/colima_download.go @@ -0,0 +1,29 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var colimaDownloadCmd = &cobra.Command{ + Use: "colima:download", + Short: "Download Colima to internal storage", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + return ui.StepProgress("Downloading Colima...", func(progress func(written, total int64)) (string, error) { + if err := colima.Install(client, progress); err != nil { + return "", fmt.Errorf("cannot download Colima: %w", err) + } + return "Colima downloaded", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(colimaDownloadCmd) +} diff --git a/cmd/colima_install.go b/cmd/colima_install.go index 199ac7f..8a1cbdd 100644 --- a/cmd/colima_install.go +++ b/cmd/colima_install.go @@ -2,11 +2,9 @@ package cmd import ( "fmt" - "net/http" "os" - "github.com/prvious/pv/internal/colima" - "github.com/prvious/pv/internal/ui" + "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) @@ -16,15 +14,17 @@ var colimaInstallCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) - client := &http.Client{} + // Download. + if err := colimaDownloadCmd.RunE(colimaDownloadCmd, nil); err != nil { + return err + } - if err := ui.StepProgress("Installing Colima...", func(progress func(written, total int64)) (string, error) { - if err := colima.Install(client, progress); err != nil { - return "", fmt.Errorf("cannot install Colima: %w", err) + // Expose (no-op for colima since AutoExpose=false). + t := tools.Get("colima") + if t != nil && t.AutoExpose { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Colima: %w", err) } - return "Colima installed", nil - }); err != nil { - return err } fmt.Fprintln(os.Stderr) diff --git a/cmd/colima_update.go b/cmd/colima_update.go new file mode 100644 index 0000000..854ddad --- /dev/null +++ b/cmd/colima_update.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var colimaUpdateCmd = &cobra.Command{ + Use: "colima:update", + Short: "Update Colima to the latest version", + RunE: func(cmd *cobra.Command, args []string) error { + if !colima.IsInstalled() { + ui.Success("Colima not installed (run: pv colima:install)") + return nil + } + + client := &http.Client{} + + return ui.StepProgress("Updating Colima...", func(progress func(written, total int64)) (string, error) { + if err := colima.Install(client, progress); err != nil { + return "", fmt.Errorf("cannot download Colima: %w", err) + } + return "Colima updated", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(colimaUpdateCmd) +} diff --git a/cmd/composer_download.go b/cmd/composer_download.go new file mode 100644 index 0000000..54b8384 --- /dev/null +++ b/cmd/composer_download.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var composerDownloadCmd = &cobra.Command{ + Use: "composer:download", + Short: "Download Composer to internal storage", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + if err := config.EnsureDirs(); err != nil { + return err + } + + return ui.StepProgress("Downloading Composer...", func(progress func(written, total int64)) (string, error) { + vs, err := binaries.LoadVersions() + if err != nil { + return "", fmt.Errorf("cannot load version state: %w", err) + } + + latest, err := binaries.FetchLatestVersion(client, binaries.Composer) + if err != nil { + return "", fmt.Errorf("cannot check Composer version: %w", err) + } + + if err := binaries.InstallBinaryProgress(client, binaries.Composer, latest, progress); err != nil { + return "", fmt.Errorf("cannot download Composer: %w", err) + } + + vs.Set("composer", latest) + if err := vs.Save(); err != nil { + return "", fmt.Errorf("cannot save versions: %w", err) + } + + return "Composer downloaded", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(composerDownloadCmd) +} diff --git a/cmd/composer_install.go b/cmd/composer_install.go index 9a38e00..3bd7abb 100644 --- a/cmd/composer_install.go +++ b/cmd/composer_install.go @@ -2,12 +2,9 @@ package cmd import ( "fmt" - "net/http" "os" - "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/ui" + "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) @@ -17,35 +14,17 @@ var composerInstallCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) - client := &http.Client{} - - if err := ui.StepProgress("Installing Composer...", func(progress func(written, total int64)) (string, error) { - if err := config.EnsureDirs(); err != nil { - return "", err - } - - vs, err := binaries.LoadVersions() - if err != nil { - return "", fmt.Errorf("cannot load version state: %w", err) - } - - latest, err := binaries.FetchLatestVersion(client, binaries.Composer) - if err != nil { - return "", fmt.Errorf("cannot check Composer version: %w", err) - } - - if err := binaries.InstallBinaryProgress(client, binaries.Composer, latest, progress); err != nil { - return "", fmt.Errorf("cannot install Composer: %w", err) - } + // Download. + if err := composerDownloadCmd.RunE(composerDownloadCmd, nil); err != nil { + return err + } - vs.Set("composer", latest) - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save versions: %w", err) + // Expose to PATH. + t := tools.Get("composer") + if t != nil && t.AutoExpose { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Composer: %w", err) } - - return "Composer installed", nil - }); err != nil { - return err } fmt.Fprintln(os.Stderr) diff --git a/cmd/composer_path.go b/cmd/composer_path.go new file mode 100644 index 0000000..5ac1e68 --- /dev/null +++ b/cmd/composer_path.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var composerPathRemove bool + +var composerPathCmd = &cobra.Command{ + Use: "composer:path", + Short: "Expose or remove Composer from PATH", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.Get("composer") + + if composerPathRemove { + if err := tools.Unexpose(t); err != nil { + return err + } + ui.Success("Composer removed from PATH") + return nil + } + + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Composer: %w", err) + } + ui.Success("Composer added to PATH") + return nil + }, +} + +func init() { + composerPathCmd.Flags().BoolVar(&composerPathRemove, "remove", false, "Remove from PATH instead of adding") + rootCmd.AddCommand(composerPathCmd) +} diff --git a/cmd/composer_update.go b/cmd/composer_update.go new file mode 100644 index 0000000..4eb9d44 --- /dev/null +++ b/cmd/composer_update.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var composerUpdateCmd = &cobra.Command{ + Use: "composer:update", + Short: "Update Composer to the latest version", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + if err := config.EnsureDirs(); err != nil { + return err + } + + // Composer always re-downloads (no version comparison). + return ui.StepProgress("Updating Composer...", func(progress func(written, total int64)) (string, error) { + vs, err := binaries.LoadVersions() + if err != nil { + return "", fmt.Errorf("cannot load version state: %w", err) + } + + if err := binaries.InstallBinaryProgress(client, binaries.Composer, "latest", progress); err != nil { + return "", fmt.Errorf("cannot download Composer: %w", err) + } + + vs.Set("composer", "latest") + if err := vs.Save(); err != nil { + return "", fmt.Errorf("cannot save versions: %w", err) + } + + t := tools.Get("composer") + if t != nil && tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return "", fmt.Errorf("cannot expose Composer: %w", err) + } + } + + return "Composer updated", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(composerUpdateCmd) +} diff --git a/cmd/daemon.go b/cmd/daemon.go index 794e877..ac439fc 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -9,14 +9,9 @@ import ( "github.com/spf13/cobra" ) -var daemonCmd = &cobra.Command{ - Use: "daemon", - Short: "Manage the pv background daemon", -} - -var daemonInstallCmd = &cobra.Command{ - Use: "install", - Short: "Install pv as a login daemon (starts on boot)", +var daemonEnableCmd = &cobra.Command{ + Use: "daemon:enable", + Short: "Enable pv as a login daemon (starts on boot)", RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) @@ -43,9 +38,9 @@ var daemonInstallCmd = &cobra.Command{ }, } -var daemonUninstallCmd = &cobra.Command{ - Use: "uninstall", - Short: "Uninstall the pv login daemon", +var daemonDisableCmd = &cobra.Command{ + Use: "daemon:disable", + Short: "Disable the pv login daemon", RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) @@ -72,7 +67,6 @@ var daemonUninstallCmd = &cobra.Command{ } func init() { - daemonCmd.AddCommand(daemonInstallCmd) - daemonCmd.AddCommand(daemonUninstallCmd) - rootCmd.AddCommand(daemonCmd) + rootCmd.AddCommand(daemonEnableCmd) + rootCmd.AddCommand(daemonDisableCmd) } diff --git a/cmd/install.go b/cmd/install.go index 876c46a..b38ff5a 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -5,6 +5,7 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "strings" "time" @@ -15,6 +16,7 @@ import ( "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/registry" "github.com/prvious/pv/internal/setup" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -148,9 +150,15 @@ var installCmd = &cobra.Command{ toolVersions = append(toolVersions, fmt.Sprintf("%s %s", b.DisplayName, displayVersion)) } - // Write shims. - if err := phpenv.WriteShims(); err != nil { - return "", fmt.Errorf("cannot write shims: %w", err) + // Migrate old composer.phar location. + oldComposer := filepath.Join(config.DataDir(), "composer.phar") + if _, err := os.Stat(oldComposer); err == nil { + os.Remove(oldComposer) + } + + // Expose tools (shims + symlinks). + if err := tools.ExposeAll(); err != nil { + return "", fmt.Errorf("cannot expose tools: %w", err) } // Migrate existing Composer config if present. diff --git a/cmd/mago_download.go b/cmd/mago_download.go new file mode 100644 index 0000000..f7fe9e8 --- /dev/null +++ b/cmd/mago_download.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var magoDownloadCmd = &cobra.Command{ + Use: "mago:download", + Short: "Download Mago to internal storage", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + if err := config.EnsureDirs(); err != nil { + return err + } + + return ui.StepProgress("Downloading Mago...", func(progress func(written, total int64)) (string, error) { + vs, err := binaries.LoadVersions() + if err != nil { + return "", fmt.Errorf("cannot load version state: %w", err) + } + + latest, err := binaries.FetchLatestVersion(client, binaries.Mago) + if err != nil { + return "", fmt.Errorf("cannot check Mago version: %w", err) + } + + if err := binaries.InstallBinaryProgress(client, binaries.Mago, latest, progress); err != nil { + return "", fmt.Errorf("cannot download Mago: %w", err) + } + + vs.Set("mago", latest) + if err := vs.Save(); err != nil { + return "", fmt.Errorf("cannot save versions: %w", err) + } + + return fmt.Sprintf("Mago %s downloaded", latest), nil + }) + }, +} + +func init() { + rootCmd.AddCommand(magoDownloadCmd) +} diff --git a/cmd/mago_install.go b/cmd/mago_install.go index 6828cc9..1a7abff 100644 --- a/cmd/mago_install.go +++ b/cmd/mago_install.go @@ -2,12 +2,9 @@ package cmd import ( "fmt" - "net/http" "os" - "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/ui" + "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) @@ -17,35 +14,17 @@ var magoInstallCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) - client := &http.Client{} - - if err := ui.StepProgress("Installing Mago...", func(progress func(written, total int64)) (string, error) { - if err := config.EnsureDirs(); err != nil { - return "", err - } - - vs, err := binaries.LoadVersions() - if err != nil { - return "", fmt.Errorf("cannot load version state: %w", err) - } - - latest, err := binaries.FetchLatestVersion(client, binaries.Mago) - if err != nil { - return "", fmt.Errorf("cannot check Mago version: %w", err) - } - - if err := binaries.InstallBinaryProgress(client, binaries.Mago, latest, progress); err != nil { - return "", fmt.Errorf("cannot install Mago: %w", err) - } + // Download. + if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + return err + } - vs.Set("mago", latest) - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save versions: %w", err) + // Expose to PATH. + t := tools.Get("mago") + if t != nil && t.AutoExpose { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Mago: %w", err) } - - return fmt.Sprintf("Mago %s installed", latest), nil - }); err != nil { - return err } fmt.Fprintln(os.Stderr) diff --git a/cmd/mago_path.go b/cmd/mago_path.go new file mode 100644 index 0000000..3277c21 --- /dev/null +++ b/cmd/mago_path.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var magoPathRemove bool + +var magoPathCmd = &cobra.Command{ + Use: "mago:path", + Short: "Expose or remove Mago from PATH", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.Get("mago") + + if magoPathRemove { + if err := tools.Unexpose(t); err != nil { + return err + } + ui.Success("Mago removed from PATH") + return nil + } + + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Mago: %w", err) + } + ui.Success("Mago added to PATH") + return nil + }, +} + +func init() { + magoPathCmd.Flags().BoolVar(&magoPathRemove, "remove", false, "Remove from PATH instead of adding") + rootCmd.AddCommand(magoPathCmd) +} diff --git a/cmd/mago_update.go b/cmd/mago_update.go new file mode 100644 index 0000000..2a05bb2 --- /dev/null +++ b/cmd/mago_update.go @@ -0,0 +1,72 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var magoUpdateCmd = &cobra.Command{ + Use: "mago:update", + Short: "Update Mago to the latest version", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + if err := config.EnsureDirs(); err != nil { + return err + } + + vs, err := binaries.LoadVersions() + if err != nil { + return fmt.Errorf("cannot load version state: %w", err) + } + + latest, err := binaries.FetchLatestVersion(client, binaries.Mago) + if err != nil { + return fmt.Errorf("cannot check Mago version: %w", err) + } + + if !binaries.NeedsUpdate(vs, binaries.Mago, latest) { + ui.Success("Mago already up to date") + return nil + } + + current := vs.Get("mago") + + if err := ui.StepProgress("Updating Mago...", func(progress func(written, total int64)) (string, error) { + if err := binaries.InstallBinaryProgress(client, binaries.Mago, latest, progress); err != nil { + return "", fmt.Errorf("cannot download Mago: %w", err) + } + + vs.Set("mago", latest) + if err := vs.Save(); err != nil { + return "", fmt.Errorf("cannot save versions: %w", err) + } + + t := tools.Get("mago") + if t != nil && tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return "", fmt.Errorf("cannot expose Mago: %w", err) + } + } + + if current != "" { + return fmt.Sprintf("Mago %s -> %s", current, latest), nil + } + return fmt.Sprintf("Mago %s", latest), nil + }); err != nil { + return err + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(magoUpdateCmd) +} diff --git a/cmd/php_download.go b/cmd/php_download.go new file mode 100644 index 0000000..180f433 --- /dev/null +++ b/cmd/php_download.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + "net/http" + "os" + + "github.com/prvious/pv/internal/phpenv" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var phpDownloadCmd = &cobra.Command{ + Use: "php:download ", + Short: "Download PHP + FrankenPHP to internal storage", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + version := args[0] + if !validPHPVersion.MatchString(version) { + fmt.Fprintln(os.Stderr) + ui.Fail(fmt.Sprintf("Invalid version format %s", ui.Bold.Render(version))) + ui.FailDetail("Use major.minor (e.g., 8.4)") + fmt.Fprintln(os.Stderr) + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + client := &http.Client{} + return ui.StepProgress("Downloading PHP "+version+"...", func(progress func(written, total int64)) (string, error) { + if err := phpenv.InstallProgress(client, version, progress); err != nil { + return "", err + } + return fmt.Sprintf("PHP %s downloaded", version), nil + }) + }, +} + +func init() { + rootCmd.AddCommand(phpDownloadCmd) +} diff --git a/cmd/php_install.go b/cmd/php_install.go index 6978486..71c5297 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -2,11 +2,11 @@ package cmd import ( "fmt" - "net/http" "os" "regexp" "github.com/prvious/pv/internal/phpenv" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -37,13 +37,8 @@ var phpInstallCmd = &cobra.Command{ fmt.Fprintln(os.Stderr) - client := &http.Client{} - if err := ui.StepProgress("Installing PHP "+version+"...", func(progress func(written, total int64)) (string, error) { - if err := phpenv.InstallProgress(client, version, progress); err != nil { - return "", err - } - return fmt.Sprintf("PHP %s installed", version), nil - }); err != nil { + // Download. + if err := phpDownloadCmd.RunE(phpDownloadCmd, []string{version}); err != nil { return err } @@ -55,6 +50,14 @@ var phpInstallCmd = &cobra.Command{ ui.Success(fmt.Sprintf("PHP %s set as global default", version)) } + // Expose PHP and FrankenPHP to PATH. + for _, name := range []string{"php", "frankenphp"} { + t := tools.Get(name) + if t != nil && t.AutoExpose { + _ = tools.Expose(t) + } + } + fmt.Fprintln(os.Stderr) return nil }, diff --git a/cmd/php_path.go b/cmd/php_path.go new file mode 100644 index 0000000..a925278 --- /dev/null +++ b/cmd/php_path.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var phpPathRemove bool + +var phpPathCmd = &cobra.Command{ + Use: "php:path", + Short: "Expose or remove PHP and FrankenPHP from PATH", + RunE: func(cmd *cobra.Command, args []string) error { + php := tools.Get("php") + fp := tools.Get("frankenphp") + + if phpPathRemove { + if err := tools.Unexpose(php); err != nil { + return err + } + if err := tools.Unexpose(fp); err != nil { + return err + } + ui.Success("PHP and FrankenPHP removed from PATH") + return nil + } + + if err := tools.Expose(php); err != nil { + return fmt.Errorf("cannot expose PHP: %w", err) + } + if err := tools.Expose(fp); err != nil { + return fmt.Errorf("cannot expose FrankenPHP: %w", err) + } + ui.Success("PHP and FrankenPHP added to PATH") + return nil + }, +} + +func init() { + phpPathCmd.Flags().BoolVar(&phpPathRemove, "remove", false, "Remove from PATH instead of adding") + rootCmd.AddCommand(phpPathCmd) +} diff --git a/cmd/php_update.go b/cmd/php_update.go new file mode 100644 index 0000000..ddf7f5b --- /dev/null +++ b/cmd/php_update.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/phpenv" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var phpUpdateCmd = &cobra.Command{ + Use: "php:update", + Short: "Re-download all installed PHP versions with the latest builds", + RunE: func(cmd *cobra.Command, args []string) error { + client := &http.Client{} + + versions, err := phpenv.InstalledVersions() + if err != nil { + return fmt.Errorf("cannot list installed versions: %w", err) + } + + if len(versions) == 0 { + ui.Success("No PHP versions installed") + return nil + } + + for _, v := range versions { + if err := ui.StepProgress(fmt.Sprintf("Updating PHP %s...", v), func(progress func(written, total int64)) (string, error) { + if err := phpenv.InstallProgress(client, v, progress); err != nil { + return "", err + } + return fmt.Sprintf("PHP %s updated", v), nil + }); err != nil { + return err + } + } + + // Re-expose only if already on PATH. + for _, name := range []string{"php", "frankenphp"} { + t := tools.Get(name) + if t != nil && tools.IsExposed(t) { + _ = tools.Expose(t) + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(phpUpdateCmd) +} diff --git a/cmd/setup.go b/cmd/setup.go index a7127ef..78edb4f 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -12,6 +12,7 @@ import ( "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -224,9 +225,9 @@ var setupCmd = &cobra.Command{ } } - // Write shims. - if err := phpenv.WriteShims(); err != nil { - fmt.Fprintf(os.Stderr, " %s Shims failed: %v\n", ui.Red.Render("!"), err) + // Expose tools (shims + symlinks). + if err := tools.ExposeAll(); err != nil { + fmt.Fprintf(os.Stderr, " %s Tool exposure failed: %v\n", ui.Red.Render("!"), err) } // Print summary. diff --git a/cmd/update.go b/cmd/update.go index ae5239f..a30b22a 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -2,103 +2,60 @@ package cmd import ( "fmt" - "strings" - "time" - "net/http" + "os" + "syscall" + "time" - "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/selfupdate" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) -var updateVerbose bool +var ( + updateVerbose bool + noSelfUpdate bool +) var updateCmd = &cobra.Command{ Use: "update", - Short: "Download and update all managed binaries", + Short: "Update pv and all managed tools to their latest versions", RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() - binaries.Verbose = updateVerbose - ui.Header(version) client := &http.Client{} - // Step 1: Check for updates. - vs, err := binaries.LoadVersions() - if err != nil { - return fmt.Errorf("cannot load version state: %w", err) + // Step 1: Self-update pv binary (unless --no-self-update). + if !noSelfUpdate { + reexeced, err := selfUpdate(client) + if err != nil { + fmt.Fprintf(os.Stderr, " %s pv self-update failed: %v\n", ui.Red.Render("!"), err) + } + if reexeced { + return nil // unreachable — syscall.Exec replaces the process + } } - type updateInfo struct { - binary binaries.Binary - latest string - current string - needed bool + // Step 2: Update tools. + if err := phpUpdateCmd.RunE(phpUpdateCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s PHP update failed: %v\n", ui.Red.Render("!"), err) } - var updates []updateInfo - var anyNeeded bool - - if err := ui.Step("Checking for updates...", func() (string, error) { - for _, b := range binaries.Tools() { - latest, err := binaries.FetchLatestVersion(client, b) - if err != nil { - return "", fmt.Errorf("cannot check %s version: %w", b.DisplayName, err) - } - needed := binaries.NeedsUpdate(vs, b, latest) - if needed { - anyNeeded = true - } - updates = append(updates, updateInfo{ - binary: b, - latest: latest, - current: vs.Get(b.Name), - needed: needed, - }) - } - if anyNeeded { - return "Updates available", nil - } - return "Already up to date", nil - }); err != nil { - return err + if err := magoUpdateCmd.RunE(magoUpdateCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Mago update failed: %v\n", ui.Red.Render("!"), err) } - if !anyNeeded { - fmt.Fprintln(cmd.OutOrStderr()) - return nil + if err := composerUpdateCmd.RunE(composerUpdateCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Composer update failed: %v\n", ui.Red.Render("!"), err) } - // Step 2: Update tools. - if err := ui.Step("Updating tools...", func() (string, error) { - var results []string - for _, u := range updates { - if !u.needed { - results = append(results, fmt.Sprintf("%s up to date", u.binary.DisplayName)) - continue - } - - if err := binaries.InstallBinary(client, u.binary, u.latest); err != nil { - return "", fmt.Errorf("cannot install %s: %w", u.binary.DisplayName, err) - } - - vs.Set(u.binary.Name, u.latest) - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save version state: %w", err) - } - - if u.current != "" { - results = append(results, fmt.Sprintf("%s %s → %s", u.binary.DisplayName, u.current, u.latest)) - } else { - results = append(results, fmt.Sprintf("%s %s", u.binary.DisplayName, u.latest)) - } + if colima.IsInstalled() { + if err := colimaUpdateCmd.RunE(colimaUpdateCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Colima update failed: %v\n", ui.Red.Render("!"), err) } - return strings.Join(results, ", "), nil - }); err != nil { - return err } ui.Footer(start, "") @@ -107,7 +64,42 @@ var updateCmd = &cobra.Command{ }, } +// selfUpdate checks for a new pv version, downloads it, and re-execs. +// Returns true if the process was re-execed (caller should return immediately). +func selfUpdate(client *http.Client) (bool, error) { + latest, needed, err := selfupdate.NeedsUpdate(client, version) + if err != nil { + return false, err + } + + if !needed { + ui.Success("pv already up to date") + return false, nil + } + + var newBinary string + if err := ui.StepProgress("Updating pv...", func(progress func(written, total int64)) (string, error) { + path, err := selfupdate.Update(client, latest, progress) + if err != nil { + return "", err + } + newBinary = path + return fmt.Sprintf("pv %s -> %s", version, latest), nil + }); err != nil { + return false, err + } + + // Re-exec the new binary with --no-self-update to continue with tool updates. + newArgs := []string{"pv", "update", "--no-self-update"} + if updateVerbose { + newArgs = append(newArgs, "--verbose") + } + + return true, syscall.Exec(newBinary, newArgs, os.Environ()) +} + func init() { updateCmd.Flags().BoolVarP(&updateVerbose, "verbose", "v", false, "Show detailed output") + updateCmd.Flags().BoolVar(&noSelfUpdate, "no-self-update", false, "Skip updating the pv binary itself") rootCmd.AddCommand(updateCmd) } diff --git a/internal/binaries/install.go b/internal/binaries/install.go index 3713c9b..3fe433f 100644 --- a/internal/binaries/install.go +++ b/internal/binaries/install.go @@ -78,9 +78,10 @@ func installFrankenPHP(client *http.Client, url string, b Binary, version string return MakeExecutable(destPath) } -func installMago(client *http.Client, url string, b Binary, binDir string, progress ProgressFunc) error { - archivePath := filepath.Join(binDir, "mago.tar.gz") - destPath := filepath.Join(binDir, "mago") +func installMago(client *http.Client, url string, b Binary, _ string, progress ProgressFunc) error { + internalBin := config.InternalBinDir() + archivePath := filepath.Join(internalBin, "mago.tar.gz") + destPath := filepath.Join(internalBin, "mago") logf(" Downloading %s...\n", b.DisplayName) if err := DownloadProgress(client, url, archivePath, progress); err != nil { diff --git a/internal/config/paths.go b/internal/config/paths.go index b97e7f0..e8ea6e9 100644 --- a/internal/config/paths.go +++ b/internal/config/paths.go @@ -78,7 +78,11 @@ func ComposerBinDir() string { } func ComposerPharPath() string { - return filepath.Join(DataDir(), "composer.phar") + return filepath.Join(InternalBinDir(), "composer.phar") +} + +func MagoPath() string { + return filepath.Join(InternalBinDir(), "mago") } func PhpDir() string { diff --git a/internal/config/paths_test.go b/internal/config/paths_test.go index aa01587..a5b165a 100644 --- a/internal/config/paths_test.go +++ b/internal/config/paths_test.go @@ -280,8 +280,18 @@ func TestComposerPharPath(t *testing.T) { t.Setenv("HOME", home) got := ComposerPharPath() - if !strings.HasSuffix(got, filepath.Join(".pv", "data", "composer.phar")) { - t.Errorf("ComposerPharPath() = %q, want suffix .pv/data/composer.phar", got) + if !strings.HasSuffix(got, filepath.Join(".pv", "internal", "bin", "composer.phar")) { + t.Errorf("ComposerPharPath() = %q, want suffix .pv/internal/bin/composer.phar", got) + } +} + +func TestMagoPath(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + got := MagoPath() + if !strings.HasSuffix(got, filepath.Join(".pv", "internal", "bin", "mago")) { + t.Errorf("MagoPath() = %q, want suffix .pv/internal/bin/mago", got) } } diff --git a/internal/phpenv/phpenv.go b/internal/phpenv/phpenv.go index aab68fd..6b20068 100644 --- a/internal/phpenv/phpenv.go +++ b/internal/phpenv/phpenv.go @@ -10,6 +10,10 @@ import ( "github.com/prvious/pv/internal/config" ) +// ExposeFunc is set by the tools package at init time to break the import cycle. +// It exposes the frankenphp tool (symlink) after a global PHP version change. +var ExposeFunc func(name string) error + // InstalledVersions returns all PHP versions that have been installed. // It scans ~/.pv/php/ for directories containing a frankenphp binary. func InstalledVersions() ([]string, error) { @@ -107,15 +111,18 @@ func Remove(version string) error { } // updateSymlinks repoints ~/.pv/bin/frankenphp to the given version. -// PHP CLI is handled by the shim script from WriteShims(), not a symlink. +// If ExposeFunc is set (by the tools package), it delegates there. +// Otherwise falls back to direct symlink creation. func updateSymlinks(version string) error { + if ExposeFunc != nil { + return ExposeFunc("frankenphp") + } binDir := config.BinDir() linkPath := filepath.Join(binDir, "frankenphp") target := FrankenPHPPath(version) - // Remove existing file/symlink. os.Remove(linkPath) if err := os.Symlink(target, linkPath); err != nil { - return fmt.Errorf("cannot create symlink %s → %s: %w", linkPath, target, err) + return fmt.Errorf("cannot create symlink %s -> %s: %w", linkPath, target, err) } return nil } diff --git a/internal/phpenv/shim.go b/internal/phpenv/shim.go index 9359d21..b468c5c 100644 --- a/internal/phpenv/shim.go +++ b/internal/phpenv/shim.go @@ -1,160 +1,20 @@ package phpenv -import ( - "fmt" - "os" - "path/filepath" - - "github.com/prvious/pv/internal/config" -) - -const phpShimScript = `#!/bin/bash -# pv PHP version shim — auto-resolves PHP version per project. -set -euo pipefail - -PV_PHP_DIR="%s" -PV_SETTINGS="%s" - -# Read global default version from settings. -global_php() { - if [ -f "$PV_SETTINGS" ]; then - # Simple JSON parse for global_php field. - grep -o '"global_php"[[:space:]]*:[[:space:]]*"[^"]*"' "$PV_SETTINGS" | \ - grep -o '"[^"]*"$' | tr -d '"' || true - fi -} - -# Walk up directories looking for .pv-php or composer.json. -resolve_version() { - local dir="$PWD" - while [ "$dir" != "/" ]; do - # Check .pv-php file. - if [ -f "$dir/.pv-php" ]; then - cat "$dir/.pv-php" | tr -d '[:space:]' - return - fi - # Check composer.json for PHP constraint (extract major.minor). - if [ -f "$dir/composer.json" ]; then - local constraint - constraint=$(grep -o '"php"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/composer.json" | \ - grep -o '"[^"]*"$' | tr -d '"' || true) - if [ -n "$constraint" ]; then - # Extract the first major.minor version from the constraint. - local ver - ver=$(echo "$constraint" | grep -o '[0-9]\+\.[0-9]\+' | head -1) - if [ -n "$ver" ] && [ -d "$PV_PHP_DIR/$ver" ]; then - echo "$ver" - return - fi - fi - fi - dir=$(dirname "$dir") - done - - # Fall back to global default. - global_php -} - -VERSION=$(resolve_version) -if [ -z "$VERSION" ]; then - echo "pv: no PHP version configured. Run: pv php:install " >&2 - exit 1 -fi - -BINARY="$PV_PHP_DIR/$VERSION/php" -if [ ! -x "$BINARY" ]; then - echo "pv: PHP $VERSION is not installed. Run: pv php:install $VERSION" >&2 - exit 1 -fi - -exec "$BINARY" "$@" -` - -const composerShimScript = `#!/bin/bash -# pv Composer shim — isolates Composer home and cache under ~/.pv/composer. -set -euo pipefail - -export COMPOSER_HOME="%s" -export COMPOSER_CACHE_DIR="%s" - -PV_PHP_DIR="%s" -PV_SETTINGS="%s" -COMPOSER_PHAR="%s" - -# Read global default version from settings. -global_php() { - if [ -f "$PV_SETTINGS" ]; then - grep -o '"global_php"[[:space:]]*:[[:space:]]*"[^"]*"' "$PV_SETTINGS" | \ - grep -o '"[^"]*"$' | tr -d '"' || true - fi -} - -# Walk up directories looking for .pv-php or composer.json. -resolve_version() { - local dir="$PWD" - while [ "$dir" != "/" ]; do - if [ -f "$dir/.pv-php" ]; then - cat "$dir/.pv-php" | tr -d '[:space:]' - return - fi - if [ -f "$dir/composer.json" ]; then - local constraint - constraint=$(grep -o '"php"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/composer.json" | \ - grep -o '"[^"]*"$' | tr -d '"' || true) - if [ -n "$constraint" ]; then - local ver - ver=$(echo "$constraint" | grep -o '[0-9]\+\.[0-9]\+' | head -1) - if [ -n "$ver" ] && [ -d "$PV_PHP_DIR/$ver" ]; then - echo "$ver" - return - fi - fi - fi - dir=$(dirname "$dir") - done - global_php +import "github.com/prvious/pv/internal/tools" + +func init() { + // Wire up the expose function to break the import cycle. + // phpenv.updateSymlinks() uses this to delegate to tools.Expose(). + ExposeFunc = func(name string) error { + t := tools.Get(name) + if t == nil { + return nil + } + return tools.Expose(t) + } } -VERSION=$(resolve_version) -if [ -z "$VERSION" ]; then - echo "pv: no PHP version configured. Run: pv php:install " >&2 - exit 1 -fi - -PHP_BINARY="$PV_PHP_DIR/$VERSION/php" -if [ ! -x "$PHP_BINARY" ]; then - echo "pv: PHP $VERSION is not installed. Run: pv php:install $VERSION" >&2 - exit 1 -fi - -exec "$PHP_BINARY" "$COMPOSER_PHAR" "$@" -` - -// WriteShims creates the php, frankenphp, and composer shim scripts in ~/.pv/bin/. +// WriteShims delegates to tools.ExposeAll() which creates all shims and symlinks. func WriteShims() error { - phpDir := config.PhpDir() - settingsPath := config.SettingsPath() - binDir := config.BinDir() - - // Write PHP shim. - phpShim := filepath.Join(binDir, "php") - content := fmt.Sprintf(phpShimScript, phpDir, settingsPath) - if err := os.WriteFile(phpShim, []byte(content), 0755); err != nil { - return fmt.Errorf("cannot write php shim: %w", err) - } - - // Write Composer shim. - composerShim := filepath.Join(binDir, "composer") - composerContent := fmt.Sprintf(composerShimScript, - config.ComposerDir(), - config.ComposerCacheDir(), - phpDir, - settingsPath, - config.ComposerPharPath(), - ) - if err := os.WriteFile(composerShim, []byte(composerContent), 0755); err != nil { - return fmt.Errorf("cannot write composer shim: %w", err) - } - - return nil + return tools.ExposeAll() } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..a1798f4 --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,161 @@ +package selfupdate + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/prvious/pv/internal/binaries" +) + +// NeedsUpdate checks if a newer pv version is available. +// Returns the latest version tag (without "v" prefix) and whether an update is needed. +func NeedsUpdate(client *http.Client, currentVersion string) (string, bool, error) { + latest, err := fetchLatestVersion(client) + if err != nil { + return "", false, err + } + + latestNorm := strings.TrimPrefix(latest, "v") + currentNorm := strings.TrimPrefix(currentVersion, "v") + + if currentNorm == "dev" || currentNorm == "" { + return latestNorm, false, nil + } + + return latestNorm, latestNorm != currentNorm, nil +} + +// Update downloads the latest pv binary and replaces the current one. +// Returns the path to the new binary. +func Update(client *http.Client, version string, progress func(written, total int64)) (string, error) { + execPath, err := os.Executable() + if err != nil { + return "", fmt.Errorf("cannot determine executable path: %w", err) + } + execPath, err = filepath.EvalSymlinks(execPath) + if err != nil { + return "", fmt.Errorf("cannot resolve executable path: %w", err) + } + + // Get current file permissions. + info, err := os.Stat(execPath) + if err != nil { + return "", fmt.Errorf("cannot stat current binary: %w", err) + } + + url := downloadURL(version) + + // Download to temp file in the same directory (ensures same filesystem for rename). + tmpFile := execPath + ".tmp" + defer os.Remove(tmpFile) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + binaries.SetGitHubHeaders(req) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("cannot download pv: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("cannot download pv: HTTP %d", resp.StatusCode) + } + + f, err := os.OpenFile(tmpFile, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, info.Mode()) + if err != nil { + return "", fmt.Errorf("cannot create temp file: %w", err) + } + + var reader io.Reader = resp.Body + if progress != nil { + reader = &progressReader{r: resp.Body, total: resp.ContentLength, progress: progress} + } + + if _, err := io.Copy(f, reader); err != nil { + f.Close() + return "", fmt.Errorf("cannot write binary: %w", err) + } + f.Close() + + // Atomic replace. + if err := os.Rename(tmpFile, execPath); err != nil { + return "", fmt.Errorf("cannot replace binary: %w", err) + } + + return execPath, nil +} + +func fetchLatestVersion(client *http.Client) (string, error) { + url := "https://api.github.com/repos/prvious/pv/releases/latest" + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + binaries.SetGitHubHeaders(req) + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("cannot check pv version: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("GitHub API returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var release struct { + TagName string `json:"tag_name"` + } + if err := json.Unmarshal(body, &release); err != nil { + return "", fmt.Errorf("cannot parse GitHub response: %w", err) + } + + return release.TagName, nil +} + +func platformString() string { + arch := runtime.GOARCH + switch arch { + case "amd64": + arch = "amd64" + case "arm64": + arch = "arm64" + } + return fmt.Sprintf("%s-%s", runtime.GOOS, arch) +} + +func downloadURL(version string) string { + version = strings.TrimPrefix(version, "v") + return fmt.Sprintf("https://github.com/prvious/pv/releases/download/v%s/pv-%s", version, platformString()) +} + +type progressReader struct { + r io.Reader + total int64 + written int64 + progress func(written, total int64) +} + +func (pr *progressReader) Read(p []byte) (int, error) { + n, err := pr.r.Read(p) + pr.written += int64(n) + if pr.progress != nil { + pr.progress(pr.written, pr.total) + } + return n, err +} diff --git a/internal/tools/shims.go b/internal/tools/shims.go new file mode 100644 index 0000000..e01102a --- /dev/null +++ b/internal/tools/shims.go @@ -0,0 +1,163 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/prvious/pv/internal/config" +) + +const phpShimScript = `#!/bin/bash +# pv PHP version shim — auto-resolves PHP version per project. +set -euo pipefail + +PV_PHP_DIR="%s" +PV_SETTINGS="%s" + +# Read global default version from settings. +global_php() { + if [ -f "$PV_SETTINGS" ]; then + # Simple JSON parse for global_php field. + grep -o '"global_php"[[:space:]]*:[[:space:]]*"[^"]*"' "$PV_SETTINGS" | \ + grep -o '"[^"]*"$' | tr -d '"' || true + fi +} + +# Walk up directories looking for .pv-php or composer.json. +resolve_version() { + local dir="$PWD" + while [ "$dir" != "/" ]; do + # Check .pv-php file. + if [ -f "$dir/.pv-php" ]; then + cat "$dir/.pv-php" | tr -d '[:space:]' + return + fi + # Check composer.json for PHP constraint (extract major.minor). + if [ -f "$dir/composer.json" ]; then + local constraint + constraint=$(grep -o '"php"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/composer.json" | \ + grep -o '"[^"]*"$' | tr -d '"' || true) + if [ -n "$constraint" ]; then + # Extract the first major.minor version from the constraint. + local ver + ver=$(echo "$constraint" | grep -o '[0-9]\+\.[0-9]\+' | head -1) + if [ -n "$ver" ] && [ -d "$PV_PHP_DIR/$ver" ]; then + echo "$ver" + return + fi + fi + fi + dir=$(dirname "$dir") + done + + # Fall back to global default. + global_php +} + +VERSION=$(resolve_version) +if [ -z "$VERSION" ]; then + echo "pv: no PHP version configured. Run: pv php:install " >&2 + exit 1 +fi + +BINARY="$PV_PHP_DIR/$VERSION/php" +if [ ! -x "$BINARY" ]; then + echo "pv: PHP $VERSION is not installed. Run: pv php:install $VERSION" >&2 + exit 1 +fi + +exec "$BINARY" "$@" +` + +const composerShimScript = `#!/bin/bash +# pv Composer shim — isolates Composer home and cache under ~/.pv/composer. +set -euo pipefail + +export COMPOSER_HOME="%s" +export COMPOSER_CACHE_DIR="%s" + +PV_PHP_DIR="%s" +PV_SETTINGS="%s" +COMPOSER_PHAR="%s" + +# Read global default version from settings. +global_php() { + if [ -f "$PV_SETTINGS" ]; then + grep -o '"global_php"[[:space:]]*:[[:space:]]*"[^"]*"' "$PV_SETTINGS" | \ + grep -o '"[^"]*"$' | tr -d '"' || true + fi +} + +# Walk up directories looking for .pv-php or composer.json. +resolve_version() { + local dir="$PWD" + while [ "$dir" != "/" ]; do + if [ -f "$dir/.pv-php" ]; then + cat "$dir/.pv-php" | tr -d '[:space:]' + return + fi + if [ -f "$dir/composer.json" ]; then + local constraint + constraint=$(grep -o '"php"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/composer.json" | \ + grep -o '"[^"]*"$' | tr -d '"' || true) + if [ -n "$constraint" ]; then + local ver + ver=$(echo "$constraint" | grep -o '[0-9]\+\.[0-9]\+' | head -1) + if [ -n "$ver" ] && [ -d "$PV_PHP_DIR/$ver" ]; then + echo "$ver" + return + fi + fi + fi + dir=$(dirname "$dir") + done + global_php +} + +VERSION=$(resolve_version) +if [ -z "$VERSION" ]; then + echo "pv: no PHP version configured. Run: pv php:install " >&2 + exit 1 +fi + +PHP_BINARY="$PV_PHP_DIR/$VERSION/php" +if [ ! -x "$PHP_BINARY" ]; then + echo "pv: PHP $VERSION is not installed. Run: pv php:install $VERSION" >&2 + exit 1 +fi + +exec "$PHP_BINARY" "$COMPOSER_PHAR" "$@" +` + +// writePhpShim writes the PHP version-resolving shim to ~/.pv/bin/php. +func writePhpShim() error { + phpDir := config.PhpDir() + settingsPath := config.SettingsPath() + binDir := config.BinDir() + + shimPath := filepath.Join(binDir, "php") + content := fmt.Sprintf(phpShimScript, phpDir, settingsPath) + if err := os.WriteFile(shimPath, []byte(content), 0755); err != nil { + return fmt.Errorf("cannot write php shim: %w", err) + } + return nil +} + +// writeComposerShim writes the Composer shim to ~/.pv/bin/composer. +func writeComposerShim() error { + binDir := config.BinDir() + + shimPath := filepath.Join(binDir, "composer") + content := fmt.Sprintf(composerShimScript, + config.ComposerDir(), + config.ComposerCacheDir(), + config.PhpDir(), + config.SettingsPath(), + config.ComposerPharPath(), + ) + if err := os.WriteFile(shimPath, []byte(content), 0755); err != nil { + return fmt.Errorf("cannot write composer shim: %w", err) + } + return nil +} diff --git a/internal/tools/tool.go b/internal/tools/tool.go new file mode 100644 index 0000000..9f2b534 --- /dev/null +++ b/internal/tools/tool.go @@ -0,0 +1,166 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/prvious/pv/internal/config" +) + +// ExposureType determines how a tool is made available on the user's PATH. +type ExposureType int + +const ( + ExposureNone ExposureType = iota // never exposed (e.g. colima) + ExposureSymlink // symlink internal/bin/X -> bin/X + ExposureShim // custom bash shim script +) + +// Tool describes a managed binary. +type Tool struct { + Name string + DisplayName string + AutoExpose bool // :install auto-calls :path + Exposure ExposureType + // InternalPath returns where the real binary lives. + InternalPath func() string + // WriteShim writes a custom shim to ~/.pv/bin/. + // Only used when Exposure == ExposureShim. + WriteShim func() error +} + +// globalPHPVersion returns the global PHP version from settings. +func globalPHPVersion() string { + s, err := config.LoadSettings() + if err != nil || s.GlobalPHP == "" { + return "" + } + return s.GlobalPHP +} + +// Registry of all managed tools, keyed by name. +var All = map[string]*Tool{ + "php": { + Name: "php", + DisplayName: "PHP", + AutoExpose: true, + Exposure: ExposureShim, + InternalPath: func() string { + return filepath.Join(config.PhpVersionDir(globalPHPVersion()), "php") + }, + WriteShim: writePhpShim, + }, + "frankenphp": { + Name: "frankenphp", + DisplayName: "FrankenPHP", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return filepath.Join(config.PhpVersionDir(globalPHPVersion()), "frankenphp") + }, + }, + "composer": { + Name: "composer", + DisplayName: "Composer", + AutoExpose: true, + Exposure: ExposureShim, + InternalPath: func() string { + return config.ComposerPharPath() + }, + WriteShim: writeComposerShim, + }, + "mago": { + Name: "mago", + DisplayName: "Mago", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return config.MagoPath() + }, + }, + "colima": { + Name: "colima", + DisplayName: "Colima", + AutoExpose: false, + Exposure: ExposureNone, + InternalPath: func() string { + return config.ColimaPath() + }, + }, +} + +// Get returns the tool with the given name, or nil. +func Get(name string) *Tool { + return All[name] +} + +// List returns all tools sorted by name. +func List() []*Tool { + var out []*Tool + for _, t := range All { + out = append(out, t) + } + sort.Slice(out, func(i, j int) bool { + return out[i].Name < out[j].Name + }) + return out +} + +// Expose creates the shim or symlink in ~/.pv/bin/ for a tool. +func Expose(t *Tool) error { + binDir := config.BinDir() + + switch t.Exposure { + case ExposureNone: + return nil + case ExposureShim: + if t.WriteShim == nil { + return fmt.Errorf("tool %s has ExposureShim but no WriteShim func", t.Name) + } + return t.WriteShim() + case ExposureSymlink: + target := t.InternalPath() + linkPath := filepath.Join(binDir, t.Name) + os.Remove(linkPath) // remove existing + if err := os.Symlink(target, linkPath); err != nil { + return fmt.Errorf("cannot create symlink %s -> %s: %w", linkPath, target, err) + } + return nil + default: + return fmt.Errorf("unknown exposure type for tool %s", t.Name) + } +} + +// Unexpose removes the shim or symlink from ~/.pv/bin/. +func Unexpose(t *Tool) error { + if t.Exposure == ExposureNone { + return nil + } + linkPath := filepath.Join(config.BinDir(), t.Name) + if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove %s: %w", linkPath, err) + } + return nil +} + +// IsExposed checks if a tool is currently on PATH (exists in ~/.pv/bin/). +func IsExposed(t *Tool) bool { + linkPath := filepath.Join(config.BinDir(), t.Name) + _, err := os.Lstat(linkPath) + return err == nil +} + +// ExposeAll exposes all tools that have AutoExpose=true. +func ExposeAll() error { + for _, t := range All { + if !t.AutoExpose { + continue + } + if err := Expose(t); err != nil { + return fmt.Errorf("cannot expose %s: %w", t.Name, err) + } + } + return nil +} diff --git a/internal/tools/tool_test.go b/internal/tools/tool_test.go new file mode 100644 index 0000000..085520c --- /dev/null +++ b/internal/tools/tool_test.go @@ -0,0 +1,203 @@ +package tools + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/prvious/pv/internal/config" +) + +func TestExpose_Symlink(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + // Create a fake binary in internal/bin. + fakeBin := filepath.Join(config.InternalBinDir(), "mago") + if err := os.WriteFile(fakeBin, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + + tool := &Tool{ + Name: "mago", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return fakeBin + }, + } + + if err := Expose(tool); err != nil { + t.Fatalf("Expose() error = %v", err) + } + + linkPath := filepath.Join(config.BinDir(), "mago") + target, err := os.Readlink(linkPath) + if err != nil { + t.Fatalf("symlink not created: %v", err) + } + if target != fakeBin { + t.Errorf("symlink target = %q, want %q", target, fakeBin) + } +} + +func TestExpose_Shim(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + php := Get("php") + if err := Expose(php); err != nil { + t.Fatalf("Expose(php) error = %v", err) + } + + shimPath := filepath.Join(config.BinDir(), "php") + info, err := os.Stat(shimPath) + if err != nil { + t.Fatalf("php shim not created: %v", err) + } + if info.Mode()&0111 == 0 { + t.Error("php shim is not executable") + } + + content, _ := os.ReadFile(shimPath) + if !strings.Contains(string(content), "#!/bin/bash") { + t.Error("php shim missing shebang") + } +} + +func TestUnexpose(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + // Create a fake file in bin/. + fakePath := filepath.Join(config.BinDir(), "mago") + if err := os.WriteFile(fakePath, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + + tool := &Tool{ + Name: "mago", + Exposure: ExposureSymlink, + } + + if err := Unexpose(tool); err != nil { + t.Fatalf("Unexpose() error = %v", err) + } + + if _, err := os.Stat(fakePath); !os.IsNotExist(err) { + t.Error("expected file to be removed") + } +} + +func TestUnexpose_NonExistent(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + tool := &Tool{Name: "nonexistent", Exposure: ExposureSymlink} + if err := Unexpose(tool); err != nil { + t.Fatalf("Unexpose() on missing file should not error, got: %v", err) + } +} + +func TestIsExposed(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + tool := &Tool{Name: "mago", Exposure: ExposureSymlink} + + if IsExposed(tool) { + t.Error("IsExposed() = true before expose") + } + + // Create the file. + if err := os.WriteFile(filepath.Join(config.BinDir(), "mago"), []byte("x"), 0755); err != nil { + t.Fatal(err) + } + + if !IsExposed(tool) { + t.Error("IsExposed() = false after creating file") + } +} + +func TestExposeAll(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + if err := config.EnsureDirs(); err != nil { + t.Fatal(err) + } + + // Create fake mago binary so symlink target exists. + fakeMago := filepath.Join(config.InternalBinDir(), "mago") + if err := os.WriteFile(fakeMago, []byte("fake"), 0755); err != nil { + t.Fatal(err) + } + + if err := ExposeAll(); err != nil { + t.Fatalf("ExposeAll() error = %v", err) + } + + // PHP shim should exist (AutoExpose=true, ExposureShim). + if _, err := os.Stat(filepath.Join(config.BinDir(), "php")); err != nil { + t.Error("php shim not created by ExposeAll") + } + + // Composer shim should exist. + if _, err := os.Stat(filepath.Join(config.BinDir(), "composer")); err != nil { + t.Error("composer shim not created by ExposeAll") + } + + // Mago symlink should exist. + if _, err := os.Lstat(filepath.Join(config.BinDir(), "mago")); err != nil { + t.Error("mago symlink not created by ExposeAll") + } + + // Colima should NOT be exposed (AutoExpose=false). + if _, err := os.Lstat(filepath.Join(config.BinDir(), "colima")); err == nil { + t.Error("colima should not be exposed by ExposeAll") + } +} + +func TestExpose_None(t *testing.T) { + tool := &Tool{Name: "colima", Exposure: ExposureNone} + if err := Expose(tool); err != nil { + t.Fatalf("Expose(ExposureNone) should be no-op, got: %v", err) + } +} + +func TestGet(t *testing.T) { + if Get("php") == nil { + t.Error("Get(php) = nil") + } + if Get("nonexistent") != nil { + t.Error("Get(nonexistent) should be nil") + } +} + +func TestList(t *testing.T) { + list := List() + if len(list) != len(All) { + t.Errorf("List() returned %d tools, want %d", len(list), len(All)) + } + // Verify sorted. + for i := 1; i < len(list); i++ { + if list[i].Name < list[i-1].Name { + t.Errorf("List() not sorted: %s before %s", list[i-1].Name, list[i].Name) + } + } +} From 2dd3aa1d0b9b00f83d93cbe32bd1bcf6422991da Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 16:53:15 -0500 Subject: [PATCH 02/16] Add :uninstall commands and colima:path, refactor pv uninstall as orchestrator Each tool now has a :uninstall command that cleans up its own binary and PATH entry with spinner UI. The main `pv uninstall` delegates tool cleanup to these commands, then handles global concerns (daemon, DNS, CA, ~/.pv). Also: - Add colima:path for power users who want colima on PATH - Change colima from ExposureNone to ExposureSymlink (AutoExpose stays false) - colima:update re-exposes if already on PATH - All uninstall steps use ui.Step spinners --- cmd/colima_path.go | 38 ++++++ cmd/colima_uninstall.go | 44 +++++++ cmd/colima_update.go | 10 ++ cmd/composer_uninstall.go | 37 ++++++ cmd/mago_uninstall.go | 33 +++++ cmd/php_uninstall.go | 36 ++++++ cmd/uninstall.go | 252 ++++++++++++++++++-------------------- internal/tools/tool.go | 4 +- 8 files changed, 318 insertions(+), 136 deletions(-) create mode 100644 cmd/colima_path.go create mode 100644 cmd/colima_uninstall.go create mode 100644 cmd/composer_uninstall.go create mode 100644 cmd/mago_uninstall.go create mode 100644 cmd/php_uninstall.go diff --git a/cmd/colima_path.go b/cmd/colima_path.go new file mode 100644 index 0000000..8c34d46 --- /dev/null +++ b/cmd/colima_path.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var colimaPathRemove bool + +var colimaPathCmd = &cobra.Command{ + Use: "colima:path", + Short: "Expose or remove Colima from PATH", + RunE: func(cmd *cobra.Command, args []string) error { + t := tools.Get("colima") + + if colimaPathRemove { + if err := tools.Unexpose(t); err != nil { + return err + } + ui.Success("Colima removed from PATH") + return nil + } + + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Colima: %w", err) + } + ui.Success("Colima added to PATH") + return nil + }, +} + +func init() { + colimaPathCmd.Flags().BoolVar(&colimaPathRemove, "remove", false, "Remove from PATH instead of adding") + rootCmd.AddCommand(colimaPathCmd) +} diff --git a/cmd/colima_uninstall.go b/cmd/colima_uninstall.go new file mode 100644 index 0000000..117828a --- /dev/null +++ b/cmd/colima_uninstall.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "os" + + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var colimaUninstallCmd = &cobra.Command{ + Use: "colima:uninstall", + Short: "Stop Colima VM and remove the binary", + RunE: func(cmd *cobra.Command, args []string) error { + if !colima.IsInstalled() { + ui.Success("Colima not installed") + return nil + } + + return ui.Step("Removing Colima...", func() (string, error) { + if colima.IsRunning() { + _ = colima.Stop() + _ = colima.Delete() + } + + if err := os.Remove(config.ColimaPath()); err != nil && !os.IsNotExist(err) { + return "", err + } + + t := tools.Get("colima") + if t != nil { + _ = tools.Unexpose(t) + } + + return "Colima removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(colimaUninstallCmd) +} diff --git a/cmd/colima_update.go b/cmd/colima_update.go index 854ddad..0af9ed7 100644 --- a/cmd/colima_update.go +++ b/cmd/colima_update.go @@ -5,6 +5,7 @@ import ( "net/http" "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -24,6 +25,15 @@ var colimaUpdateCmd = &cobra.Command{ if err := colima.Install(client, progress); err != nil { return "", fmt.Errorf("cannot download Colima: %w", err) } + + // Re-expose if already on PATH. + t := tools.Get("colima") + if t != nil && tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return "", fmt.Errorf("cannot expose Colima: %w", err) + } + } + return "Colima updated", nil }) }, diff --git a/cmd/composer_uninstall.go b/cmd/composer_uninstall.go new file mode 100644 index 0000000..576e838 --- /dev/null +++ b/cmd/composer_uninstall.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "os" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var composerUninstallCmd = &cobra.Command{ + Use: "composer:uninstall", + Short: "Remove Composer PHAR, PATH entry, and global packages", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.Step("Removing Composer...", func() (string, error) { + t := tools.Get("composer") + if t != nil { + _ = tools.Unexpose(t) + } + + if err := os.Remove(config.ComposerPharPath()); err != nil && !os.IsNotExist(err) { + return "", err + } + + if err := os.RemoveAll(config.ComposerDir()); err != nil { + return "", err + } + + return "Composer removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(composerUninstallCmd) +} diff --git a/cmd/mago_uninstall.go b/cmd/mago_uninstall.go new file mode 100644 index 0000000..1849014 --- /dev/null +++ b/cmd/mago_uninstall.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "os" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var magoUninstallCmd = &cobra.Command{ + Use: "mago:uninstall", + Short: "Remove Mago binary and PATH entry", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.Step("Removing Mago...", func() (string, error) { + t := tools.Get("mago") + if t != nil { + _ = tools.Unexpose(t) + } + + if err := os.Remove(config.MagoPath()); err != nil && !os.IsNotExist(err) { + return "", err + } + + return "Mago removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(magoUninstallCmd) +} diff --git a/cmd/php_uninstall.go b/cmd/php_uninstall.go new file mode 100644 index 0000000..d68253b --- /dev/null +++ b/cmd/php_uninstall.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/tools" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var phpUninstallCmd = &cobra.Command{ + Use: "php:uninstall", + Short: "Remove all PHP versions and PATH entries", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.Step("Removing PHP...", func() (string, error) { + for _, name := range []string{"php", "frankenphp"} { + t := tools.Get(name) + if t != nil { + _ = tools.Unexpose(t) + } + } + + if err := os.RemoveAll(config.PhpDir()); err != nil { + return "", fmt.Errorf("cannot remove PHP directory: %w", err) + } + + return "All PHP versions removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(phpUninstallCmd) +} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 5204280..7355061 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -11,12 +11,12 @@ import ( "syscall" "time" - "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/registry" "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/setup" + "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -24,48 +24,48 @@ var uninstallCmd = &cobra.Command{ Use: "uninstall", Short: "Completely remove pv and all its data", RunE: func(cmd *cobra.Command, args []string) error { - // Task 1: Confirmation prompt. - fmt.Println() - fmt.Println("This will remove:") - fmt.Println(" • The pv binary") - fmt.Println(" • All PHP versions and FrankenPHP binaries") - fmt.Println(" • All Composer global packages and cache") - fmt.Println(" • All project links (your project files are NOT deleted)") - fmt.Println(" • DNS resolver configuration") - fmt.Println(" • Trusted CA certificate") - fmt.Println(" • Launchd service") - fmt.Println() - fmt.Println("Your projects themselves will not be touched.") - fmt.Println() - fmt.Print("Type \"uninstall\" to confirm: ") + // Confirmation prompt. + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "This will remove:") + fmt.Fprintln(os.Stderr, " - The pv binary") + fmt.Fprintln(os.Stderr, " - All PHP versions and FrankenPHP binaries") + fmt.Fprintln(os.Stderr, " - All Composer global packages and cache") + fmt.Fprintln(os.Stderr, " - All project links (your project files are NOT deleted)") + fmt.Fprintln(os.Stderr, " - DNS resolver configuration") + fmt.Fprintln(os.Stderr, " - Trusted CA certificate") + fmt.Fprintln(os.Stderr, " - Launchd service") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Your projects themselves will not be touched.") + fmt.Fprintln(os.Stderr) + fmt.Fprint(os.Stderr, "Type \"uninstall\" to confirm: ") scanner := bufio.NewScanner(os.Stdin) scanner.Scan() if strings.TrimSpace(scanner.Text()) != "uninstall" { - fmt.Println("Aborted.") + fmt.Fprintln(os.Stderr, "Aborted.") return nil } - fmt.Println() + fmt.Fprintln(os.Stderr) - // Task 2: Auth backup offer. + // Auth backup offer. authPath := filepath.Join(config.ComposerDir(), "auth.json") if hasAuthTokens(authPath) { - fmt.Print("Back up Composer auth tokens to ~/pv-auth-backup.json? [Y/n] ") + fmt.Fprint(os.Stderr, "Back up Composer auth tokens to ~/pv-auth-backup.json? [Y/n] ") scanner.Scan() answer := strings.TrimSpace(strings.ToLower(scanner.Text())) if answer == "" || answer == "y" || answer == "yes" { home, _ := os.UserHomeDir() backupPath := filepath.Join(home, "pv-auth-backup.json") if err := copyFile(authPath, backupPath); err != nil { - fmt.Printf(" Warning: could not back up auth tokens: %v\n", err) + fmt.Fprintf(os.Stderr, " Warning: could not back up auth tokens: %v\n", err) } else { - fmt.Printf(" Backed up to %s\n", backupPath) + ui.Success(fmt.Sprintf("Backed up to %s", backupPath)) } } - fmt.Println() + fmt.Fprintln(os.Stderr) } - // Task 3: Read registry before deletion. + // Read registry before deletion (for .pv-php file scan later). var projectPaths []string reg, err := registry.Load() if err == nil { @@ -74,151 +74,135 @@ var uninstallCmd = &cobra.Command{ } } - // Load settings to know the TLD for resolver cleanup. settings, _ := config.LoadSettings() tld := settings.TLD - // Task 3b: Stop service containers and Colima. - svcs := reg.ListServices() - if len(svcs) > 0 { - fmt.Println("Stopping service containers...") - for key, svc := range svcs { - if svc.ContainerID != "" { - fmt.Printf(" Stopping %s...\n", key) - // Docker SDK: StopAndRemove(svc.ContainerID) - } - } - fmt.Println(" Done") - } + // Uninstall tools (each cleans up its own binary + PATH entry). + _ = colimaUninstallCmd.RunE(colimaUninstallCmd, nil) + _ = phpUninstallCmd.RunE(phpUninstallCmd, nil) + _ = magoUninstallCmd.RunE(magoUninstallCmd, nil) + _ = composerUninstallCmd.RunE(composerUninstallCmd, nil) - if colima.IsInstalled() && colima.IsRunning() { - fmt.Println("Stopping Colima VM...") - _ = colima.Stop() - _ = colima.Delete() - fmt.Println(" Done") - } - - // Task 4: Stop all services. - fmt.Println("Stopping services...") - if daemon.IsLoaded() { - if err := daemon.Unload(); err != nil { - fmt.Printf(" Warning: could not unload daemon: %v\n", err) - } - // Wait for clean shutdown. - for i := 0; i < 25; i++ { - time.Sleep(200 * time.Millisecond) - if !daemon.IsLoaded() { - break + // Stop services. + if err := ui.Step("Stopping services...", func() (string, error) { + if daemon.IsLoaded() { + if err := daemon.Unload(); err != nil { + return "", fmt.Errorf("could not unload daemon: %w", err) } - } - } - - // Also check foreground mode PID. - if pid, err := server.ReadPID(); err == nil { - if proc, err := os.FindProcess(pid); err == nil { - _ = proc.Signal(syscall.SIGTERM) - // Wait for exit. for i := 0; i < 25; i++ { time.Sleep(200 * time.Millisecond) - if proc.Signal(syscall.Signal(0)) != nil { + if !daemon.IsLoaded() { break } } - // Force kill if still alive. - if proc.Signal(syscall.Signal(0)) == nil { - _ = proc.Signal(syscall.SIGKILL) + } + + if pid, err := server.ReadPID(); err == nil { + if proc, err := os.FindProcess(pid); err == nil { + _ = proc.Signal(syscall.SIGTERM) + for i := 0; i < 25; i++ { + time.Sleep(200 * time.Millisecond) + if proc.Signal(syscall.Signal(0)) != nil { + break + } + } + if proc.Signal(syscall.Signal(0)) == nil { + _ = proc.Signal(syscall.SIGKILL) + } } } - } - fmt.Println(" Done") - // Task 5: Remove launchd plist. - fmt.Println("Removing launchd service...") - if err := daemon.Uninstall(); err != nil { - fmt.Printf(" Warning: %v\n", err) - } else { - fmt.Println(" Done") + return "Services stopped", nil + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) } - // Task 6: Remove system configuration (sudo). - fmt.Println("Removing system configuration...") - fmt.Println(" This requires administrator privileges.") + // Remove launchd plist. + if err := ui.Step("Removing launchd service...", func() (string, error) { + if err := daemon.Uninstall(); err != nil { + return "", err + } + return "Launchd service removed", nil + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + } - // Remove DNS resolver file. - resolverRemoved := runSudo(fmt.Sprintf("rm -f /etc/resolver/%s", tld)) - if resolverRemoved { - fmt.Println(" Removed DNS resolver") - } else { - fmt.Printf(" Warning: could not remove /etc/resolver/%s. Clean up manually:\n", tld) - fmt.Printf(" sudo rm -f /etc/resolver/%s\n", tld) + // Remove system configuration (sudo). + if err := ui.Step("Removing DNS resolver...", func() (string, error) { + if runSudo(fmt.Sprintf("rm -f /etc/resolver/%s", tld)) { + return "DNS resolver removed", nil + } + return "", fmt.Errorf("could not remove /etc/resolver/%s — run: sudo rm -f /etc/resolver/%s", tld, tld) + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) } - // Untrust CA certificate (may trigger keychain dialog, so use a timeout). + // Untrust CA certificate. caCertPath := config.CACertPath() if _, err := os.Stat(caCertPath); err == nil { - certCmd := exec.Command("sudo", "-n", "security", "remove-trusted-cert", "-d", caCertPath) - certCmd.Stdout = os.Stdout - certCmd.Stderr = os.Stderr + if err := ui.Step("Removing CA certificate...", func() (string, error) { + certCmd := exec.Command("sudo", "-n", "security", "remove-trusted-cert", "-d", caCertPath) + certCmd.Stdout = os.Stdout + certCmd.Stderr = os.Stderr + + if err := certCmd.Start(); err != nil { + return "", fmt.Errorf("could not untrust CA — run: sudo security remove-trusted-cert -d %s", caCertPath) + } - if err := certCmd.Start(); err == nil { done := make(chan error, 1) go func() { done <- certCmd.Wait() }() select { case err := <-done: - if err == nil { - fmt.Println(" Removed CA certificate") - } else { - fmt.Println(" Warning: could not untrust CA certificate. Clean up manually:") - fmt.Printf(" sudo security remove-trusted-cert -d %s\n", caCertPath) + if err != nil { + return "", fmt.Errorf("could not untrust CA — run: sudo security remove-trusted-cert -d %s", caCertPath) } + return "CA certificate removed", nil case <-time.After(10 * time.Second): certCmd.Process.Kill() <-done - fmt.Println(" Warning: CA certificate removal timed out. Clean up manually:") - fmt.Printf(" sudo security remove-trusted-cert -d %s\n", caCertPath) + return "", fmt.Errorf("CA removal timed out — run: sudo security remove-trusted-cert -d %s", caCertPath) } - } else { - fmt.Println(" Warning: could not untrust CA certificate. Clean up manually:") - fmt.Printf(" sudo security remove-trusted-cert -d %s\n", caCertPath) + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) } } - // Task 7: Remove ~/.pv directory. - fmt.Println("Removing ~/.pv...") - pvDir := config.PvDir() - if err := os.RemoveAll(pvDir); err != nil { - // Some files may be owned by root (e.g. from sudo -E pv start). - // Fall back to sudo rm -rf. - if runSudo(fmt.Sprintf("rm -rf '%s'", pvDir)) { - fmt.Println(" Done") - } else { - fmt.Printf(" Warning: could not fully remove %s: %v\n", pvDir, err) + // Remove ~/.pv directory. + if err := ui.Step("Removing ~/.pv...", func() (string, error) { + pvDir := config.PvDir() + if err := os.RemoveAll(pvDir); err != nil { + if runSudo(fmt.Sprintf("rm -rf '%s'", pvDir)) { + return "~/.pv removed", nil + } + return "", fmt.Errorf("could not fully remove %s", pvDir) } - } else { - fmt.Println(" Done") + return "~/.pv removed", nil + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) } // Remove the pv binary itself. - fmt.Println("Removing pv binary...") - if pvBin, err := os.Executable(); err == nil { - // Resolve symlinks to get the real path. + if err := ui.Step("Removing pv binary...", func() (string, error) { + pvBin, err := os.Executable() + if err != nil { + return "", err + } if resolved, err := filepath.EvalSymlinks(pvBin); err == nil { pvBin = resolved } if err := os.Remove(pvBin); err != nil { - // May need sudo if installed in /usr/local/bin. if runSudo(fmt.Sprintf("rm -f '%s'", pvBin)) { - fmt.Printf(" Removed %s\n", pvBin) - } else { - fmt.Printf(" Warning: could not remove %s. Delete it manually.\n", pvBin) + return fmt.Sprintf("Removed %s", pvBin), nil } - } else { - fmt.Printf(" Removed %s\n", pvBin) + return "", fmt.Errorf("could not remove %s — delete it manually", pvBin) } + return fmt.Sprintf("Removed %s", pvBin), nil + }); err != nil { + fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) } - // Task 8: Report scattered .pv-php files. - fmt.Println() + // Report scattered .pv-php files. + fmt.Fprintln(os.Stderr) var found []string for _, p := range projectPaths { pvPhpPath := filepath.Join(p, ".pv-php") @@ -227,26 +211,26 @@ var uninstallCmd = &cobra.Command{ } } if len(found) > 0 { - fmt.Println("Found .pv-php files in your projects:") + fmt.Fprintln(os.Stderr, "Found .pv-php files in your projects:") for _, f := range found { - fmt.Printf(" %s\n", f) + fmt.Fprintf(os.Stderr, " %s\n", f) } - fmt.Println("You can safely delete these.") - fmt.Println() + fmt.Fprintln(os.Stderr, "You can safely delete these.") + fmt.Fprintln(os.Stderr) } - // Task 9: Print manual steps. + // Print manual steps. shell := setup.DetectShell() configFile := setup.ShellConfigFile(shell) exportLine := setup.PathExportLine(shell) - fmt.Println("Done! Just remove the pv lines from your shell config:") - fmt.Println() - fmt.Printf(" # Remove from %s:\n", configFile) - fmt.Printf(" %s\n", exportLine) - fmt.Println(" eval \"$(pv env)\" # if present") - fmt.Println() - fmt.Println("pv has been completely uninstalled. Your projects were not modified.") + fmt.Fprintln(os.Stderr, "Done! Just remove the pv lines from your shell config:") + fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, " # Remove from %s:\n", configFile) + fmt.Fprintf(os.Stderr, " %s\n", exportLine) + fmt.Fprintln(os.Stderr, " eval \"$(pv env)\" # if present") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "pv has been completely uninstalled. Your projects were not modified.") return nil }, diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 9f2b534..6ec08eb 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -13,7 +13,7 @@ import ( type ExposureType int const ( - ExposureNone ExposureType = iota // never exposed (e.g. colima) + ExposureNone ExposureType = iota // never exposed automatically or manually ExposureSymlink // symlink internal/bin/X -> bin/X ExposureShim // custom bash shim script ) @@ -84,7 +84,7 @@ var All = map[string]*Tool{ Name: "colima", DisplayName: "Colima", AutoExpose: false, - Exposure: ExposureNone, + Exposure: ExposureSymlink, InternalPath: func() string { return config.ColimaPath() }, From 199a052699540c9c9a8eccaeda47d4938337149a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 17:41:24 -0500 Subject: [PATCH 03/16] Add daemon:restart, rewrite CLAUDE.md as instructions, expand README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract daemon:restart as standalone colon-namespaced command - Delegate pv restart → daemon:restart in daemon mode - Rewrite CLAUDE.md from documentation to concise rules/conventions - Expand README with tool management, services, daemon, architecture --- CLAUDE.md | 165 +++++++++++++++++++++---------------------------- README.md | 141 +++++++++++++++++++++++++++++++++++++++--- cmd/daemon.go | 20 ++++++ cmd/restart.go | 14 +---- 4 files changed, 225 insertions(+), 115 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4fb02c1..3535dec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,116 +1,95 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Instructions for working in this codebase. See `README.md` for architecture overview. ## What is pv -`pv` is a local development server manager powered by FrankenPHP (Caddy + embedded PHP). It replaces Docker for local dev by managing FrankenPHP instances that serve projects under `.test` domains with HTTPS. Supports multiple PHP versions simultaneously. Written in Go using cobra for CLI commands. See `plan.md` for the full vision. +`pv` is a local dev server manager powered by FrankenPHP. Go + cobra CLI. Manages PHP versions, serves projects under `.test` domains with HTTPS, runs containerized backing services via Colima/Docker. -## Commands +## Build & test ```bash -go build -o pv . # build the binary -go test ./... # run all tests -go test ./internal/registry/ # run tests for one package -go test ./cmd/ -run TestLink # run tests matching a pattern -go test ./... -v # verbose output +go build -o pv . # build +go test ./... # all tests +go test ./internal/registry/ # one package +go test ./cmd/ -run TestLink # pattern match ``` -## Architecture +Build version is set via `go build -ldflags "-X github.com/prvious/pv/cmd.version=1.0.0"` — defaults to `"dev"`. -``` -main.go # entry point — calls cmd.Execute() -cmd/ # cobra commands - root.go # rootCmd, Execute() - link.go, unlink.go, list.go # project management - start.go, stop.go, restart.go, status.go, log.go # server lifecycle - install.go, update.go # first-time setup and updates - php.go, php_install.go, php_list.go, php_remove.go # PHP version management - use.go # switch global PHP version -internal/ - config/ # path helpers for ~/.pv/ directory structure - paths.go # PvDir, PhpDir, PhpVersionDir, PortForVersion, etc. - settings.go # TLD + GlobalPHP settings - registry/ # project registry (JSON in ~/.pv/data/registry.json) - registry.go # Project{Name,Path,Type,PHP}, Registry with CRUD + GroupByPHP - tools/ # tool abstraction layer - tool.go # Tool struct, registry, Expose/Unexpose/IsExposed/ExposeAll - shims.go # PHP + Composer shim scripts - phpenv/ # PHP version management - phpenv.go # InstalledVersions, IsInstalled, SetGlobal, Remove - install.go # Download FrankenPHP from prvious/pv releases + PHP CLI from static-php.dev - resolve.go # ResolveVersion: .pv-php → composer.json → global default - available.go # AvailableVersions from GitHub releases - shim.go # WriteShims (delegates to tools.ExposeAll) - caddy/ # Caddyfile generation (multi-version aware) - caddy.go # GenerateSiteConfig(project, globalPHP), GenerateAllConfigs, GenerateVersionCaddyfile - server/ # process management - process.go # Start supervisor (DNS + main FP + secondary FPs), ReconfigureServer - frankenphp.go # StartFrankenPHP, StartVersionFrankenPHP, Reload - dns.go # Embedded DNS server on port 10053 - binaries/ # binary download (Mago, Composer) - detection/ # project type detection (laravel, php, static) - setup/ # install prerequisites, resolver, selftest -``` +## Command conventions -## Directory layout (~/.pv/) +- **Colon-namespaced**: tool/service/daemon commands use `tool:action` format (e.g., `mago:install`, `service:add`, `daemon:enable`). Core commands (`link`, `start`, `stop`) are plain. +- **All commands register on `rootCmd`** — cobra requires a flat `cmd/` directory. No subdirectories. +- **Always use `RunE`** (not `Run`) so errors propagate. +- **Command files are named `_.go`** (e.g., `mago_install.go`, `service_add.go`). -``` -~/.pv/ -├── bin/ # User PATH — shims and symlinks ONLY -│ ├── php # Shim (version resolution) -│ ├── composer # Shim (wraps PHAR with PHP) -│ ├── frankenphp → ../php/{ver}/frankenphp # Symlink -│ └── mago → ../internal/bin/mago # Symlink -├── internal/bin/ # pv's private toolbox — never on PATH -│ ├── colima # Container runtime -│ ├── mago # Real binary -│ └── composer.phar # Real PHAR -├── config/ # Caddyfiles + settings.json -│ ├── Caddyfile # main process -│ ├── php-8.3.Caddyfile # secondary process (if needed) -│ ├── sites/ # per-project configs for main process -│ └── sites-8.3/ # per-project configs for secondary process -├── data/ # registry.json, versions.json, pv.pid -├── logs/ # caddy.log, caddy-8.3.log -└── php/ # per-version binaries - ├── 8.3/frankenphp + php - ├── 8.4/frankenphp + php - └── 8.5/frankenphp + php -``` +## Tool command rules + +Every managed tool (php, mago, composer, colima) follows a strict five-command pattern. When adding a new tool, create all five: + +| Command | What it does | Where logic lives | +|---------|-------------|-------------------| +| `:download` | Fetches binary to private storage | `internal/binaries/` or `internal/phpenv/` | +| `:path` | Exposes/unexposes from PATH (supports `--remove`) | `internal/tools/` | +| `:install` | Orchestrates `:download` then `tools.Expose()` | `cmd/` — delegates only | +| `:update` | Redownloads, re-exposes if `tools.IsExposed()` | `cmd/` + `internal/` | +| `:uninstall` | Unexposes + removes binary files | `cmd/` + `internal/tools/` | + +**Hard rules:** +1. `:install` MUST delegate to `:download` RunE — never inline download logic in `cmd/`. +2. Download logic lives in `internal/binaries/` or `internal/phpenv/`, never in `cmd/`. +3. Exposure logic lives in `internal/tools/` — use `tools.Expose()` / `tools.Unexpose()`. +4. `:update` uses `tools.IsExposed()` (not `AutoExpose`) to decide re-exposure — handles manually-exposed tools correctly. +5. New tools must be registered in `internal/tools/tool.go`'s `All` map with correct `ExposureType` and `AutoExpose`. + +## Orchestrator commands + +`install`, `update`, and `uninstall` are thin orchestrators. They call per-tool `:install`/`:update`/`:uninstall` RunE functions. They MUST NOT contain download, exposure, or cleanup logic — that belongs in the per-tool commands. + +- `pv update` self-updates the pv binary first (via `syscall.Exec` re-exec with `--no-self-update`), then delegates to each tool's `:update`. +- `pv restart` delegates to `daemon:restart` in daemon mode, otherwise reloads config via admin API. + +## Binary storage rules + +- `~/.pv/bin/` — user PATH. **Only** shims and symlinks go here. Never place real binaries. +- `~/.pv/internal/bin/` — private storage. Real binaries (mago, composer.phar, colima) live here. +- `~/.pv/php/{ver}/` — versioned PHP binaries (php, frankenphp) live here. +- Use `config.InternalBinDir()` for private storage paths, `config.BinDir()` for PATH entries. + +## UI rules -## Multi-version architecture +All user-facing operations MUST use `internal/ui/` helpers. Never use raw `fmt.Print` for status output. -- **Main FrankenPHP** (global version): serves on :443/:80, handles projects using the global PHP version directly via `php_server`, and proxies non-global projects via `reverse_proxy`. -- **Secondary FrankenPHP** (per non-global version): serves on high port (8830 for 8.3, 8840 for 8.4, etc.), HTTP only, `admin off`. The main process proxies to these. -- **Port scheme**: `8000 + major*100 + minor*10` (e.g., PHP 8.3 → 8830). -- FrankenPHP binaries come from `prvious/pv` GitHub releases (format: `frankenphp-{platform}-php{version}`). +- **Long operations**: wrap in `ui.Step(label, fn)` — shows spinner, then `✓ result` or `✗ error`. +- **Downloads**: use `ui.StepProgress(label, fn)` — shows progress bar with percentage. +- **Multi-step commands**: use `ui.Header(version)` at start, `ui.Footer(start, msg)` at end. +- **Lists/tables**: use `ui.Table(headers, rows)` or `ui.Tree(items)`. +- **One-liners**: `ui.Success(text)`, `ui.Fail(text)`, `ui.Subtle(text)`. +- All output goes to `os.Stderr` (stdout is reserved for machine-readable output like `pv env`). -## Testing strategy +## Import cycle: phpenv ↔ tools -- **Unit tests** (`go test ./...`): Run locally. Use `t.Setenv("HOME", t.TempDir())` for filesystem isolation. Fake binaries (bash scripts) can stand in for real PHP when testing shims. -- **E2E tests** (`.github/workflows/e2e.yml` + `scripts/e2e/`): Run on GitHub Actions (macOS runner) to simulate real end-user flows. These tests use real PHP, real Composer, real FrankenPHP — things we can't easily run locally. **When your feature involves real binary execution, network calls, DNS, HTTPS, or anything that needs a full `pv install` environment, add an e2e script in `scripts/e2e/` and wire it into the workflow.** Each script sources `scripts/e2e/helpers.sh` for `assert_contains`, `assert_fails`, `curl_site`, etc. The workflow phases run sequentially: install → verify → fixtures → link → start → curl → shim → composer → errors → stop → lifecycle → update → verify-final. +`phpenv` and `tools` cannot import each other. This is resolved via callback: +- `phpenv.ExposeFunc` is a `func(name string) error` variable +- `phpenv/shim.go` init() wires it to `tools.Expose()` +- When adding new cross-package dependencies, use the same callback pattern — don't create import cycles. -## Tool command pattern (:download / :path / :install) +## Testing conventions -Every managed tool follows a strict three-command pattern: +- **Filesystem isolation**: always use `t.Setenv("HOME", t.TempDir())` — never touch the real home dir. +- **Cmd tests**: build fresh cobra command trees per test to avoid state leaking. +- **Registry**: in-memory + explicit save. `Load()` → mutate → `Save()`. +- **E2E tests**: live in `scripts/e2e/`, run on GitHub Actions (macOS). Source `scripts/e2e/helpers.sh`. Use these for anything needing real binaries, network, DNS, or HTTPS. Add new phases to `.github/workflows/e2e.yml`. -- **`:download`** — Fetches the binary to private storage (`internal/bin/` or `php/{ver}/`). Contains the actual download logic. -- **`:path`** — Exposes the binary to the user's PATH (`~/.pv/bin/`) via shim or symlink. Supports `--remove` to unexpose. -- **`:install`** — Orchestrator only. Calls `:download` RunE, then `tools.Expose()`. **Never duplicates download logic.** +## Multi-version PHP -Rules: -1. `:install` must always delegate to `:download` — never inline download logic. -2. Download logic lives in `internal/binaries/` or `internal/phpenv/`, not in `cmd/`. -3. Exposure logic lives in `internal/tools/` — shims and symlinks are managed by `tools.Expose()` / `tools.Unexpose()`. -4. The `internal/tools/` package defines each tool's `ExposureType` (None, Symlink, Shim) and `AutoExpose` flag. -5. `tools.ExposeAll()` is the single entry point for batch-exposing all auto-exposed tools (called by `pv install` and `pv setup`). +- Main FrankenPHP serves on :443/:80, proxies non-global versions via `reverse_proxy`. +- Secondary FrankenPHP per version on high port: `8000 + major*100 + minor*10` (8.3 → 8830). +- Version resolution order: `.pv-php` file → `composer.json` require.php → global default. -## Key patterns +## Services -- **Test isolation**: Tests use `t.Setenv("HOME", t.TempDir())` so filesystem ops go to a temp dir. -- **Cmd tests**: Build fresh cobra command trees per test to avoid state leaking. -- **Registry is in-memory + explicit save**: `Load()` → mutate → `Save()`. -- **Commands use `RunE`** (not `Run`) so errors propagate. -- **Version resolution**: `.pv-php` file → `composer.json` require.php → global default. -- **Caddy site config**: `GenerateSiteConfig(project, globalPHP)` — empty globalPHP = single-version mode. +- Each backing service (mysql, postgres, redis, mail, s3) implements `services.Service` interface. +- Services run as Docker containers via Colima. Container operations go through `container.Engine`. +- Service commands use `service:action` format. New services need: implementation in `internal/services/`, command in `cmd/service_*.go`. diff --git a/README.md b/README.md index 4f5f3c3..ef68e67 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Currently supports PHP projects (Laravel, generic PHP, static sites). pv install # Install a PHP version -pv php install 8.4 +pv php:install 8.4 # Link a project — it's now live at https://my-app.test pv link ~/code/my-app @@ -41,9 +41,6 @@ pv stop pv restart pv log -# Unlink a project -pv unlink my-app - # Unlink a project pv unlink my-app ``` @@ -54,20 +51,144 @@ pv unlink my-app ```bash # Install multiple versions -pv php install 8.3 -pv php install 8.4 -pv php install 8.5 +pv php:install 8.3 +pv php:install 8.4 +pv php:install 8.5 # Switch the global default -pv use 8.4 +pv php:use 8.4 # See what's installed -pv php list +pv php:list # Remove a version -pv php remove 8.3 +pv php:remove 8.3 ``` Per-project versions are supported too — drop a `.pv-php` file in your project root or let `pv` read the PHP constraint from `composer.json`. Multiple PHP versions run simultaneously, each project served by its own FrankenPHP process. `pv link` auto-detects your project type (Laravel, Laravel + Octane, generic PHP, static) and generates the right server configuration automatically. + +### Tool management + +Each managed tool has a consistent set of commands: + +```bash +# Download a tool to private storage +pv mago:download + +# Expose/remove from PATH +pv mago:path +pv mago:path --remove + +# Install (download + expose) +pv mago:install + +# Update to latest version +pv mago:update + +# Uninstall (remove binary + PATH entry) +pv mago:uninstall +``` + +This pattern applies to all tools: `php`, `mago`, `composer`, `colima`. + +### Backing services + +Containerized services for databases, caching, and more — powered by Colima/Docker: + +```bash +# Add a service +pv service:add mysql +pv service:add redis 7 + +# Manage services +pv service:start mysql +pv service:stop mysql +pv service:status +pv service:list + +# Inject credentials into your project's .env +pv service:env my-app + +# View logs +pv service:logs mysql + +# Remove or destroy +pv service:remove mysql +pv service:destroy mysql +``` + +Available services: MySQL, PostgreSQL, Redis, Mail (Mailpit), S3 (MinIO). + +### Daemon mode + +Run pv as a background service that starts on login: + +```bash +pv daemon:enable # Install + start daemon +pv daemon:disable # Stop + uninstall daemon +pv daemon:restart # Restart the daemon +``` + +### Update & uninstall + +```bash +pv update # Self-update pv + all tools +pv update --no-self-update # Only update tools +pv uninstall # Complete removal with guided cleanup +``` + +## Architecture + +``` +~/.pv/ +├── bin/ # User PATH — shims and symlinks only +│ ├── php # Shim (version resolution) +│ ├── composer # Shim (wraps PHAR with PHP) +│ ├── frankenphp # Symlink → ../php/{ver}/frankenphp +│ ├── mago # Symlink → ../internal/bin/mago +│ └── colima # Symlink → ../internal/bin/colima (opt-in) +├── internal/bin/ # Private storage — real binaries +│ ├── colima +│ ├── mago +│ └── composer.phar +├── config/ # Server configuration +│ ├── Caddyfile +│ ├── settings.json +│ ├── sites/ # Per-project Caddyfile includes +│ └── sites-{ver}/ # Per-version site configs +├── data/ # Registry, PID file +├── logs/ # Server logs +└── php/ # Versioned PHP binaries + └── {ver}/frankenphp + php +``` + +### Multi-version PHP + +The main FrankenPHP process (global PHP version) serves on :443/:80. Projects using a different PHP version are proxied to secondary FrankenPHP processes running on high ports (`8000 + major*100 + minor*10`, e.g., PHP 8.3 → port 8830). + +Version resolution: `.pv-php` file → `composer.json` `require.php` → global default. + +### Source layout + +``` +main.go # Entry point +cmd/ # CLI commands (flat, colon-namespaced) +internal/ + tools/ # Tool abstraction (exposure, shims, symlinks) + config/ # Path helpers, settings + registry/ # Project registry + phpenv/ # PHP version management + caddy/ # Caddyfile generation + server/ # Process management, DNS + daemon/ # macOS launchd integration + binaries/ # Binary download helpers + selfupdate/ # pv self-update + colima/ # Container runtime + container/ # Docker abstraction + services/ # Backing service definitions + detection/ # Project type detection + setup/ # Prerequisites, shell config + ui/ # Terminal UI (lipgloss) +``` diff --git a/cmd/daemon.go b/cmd/daemon.go index ac439fc..b57f0a2 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -66,7 +66,27 @@ var daemonDisableCmd = &cobra.Command{ }, } +var daemonRestartCmd = &cobra.Command{ + Use: "daemon:restart", + Short: "Restart the pv daemon", + RunE: func(cmd *cobra.Command, args []string) error { + if !daemon.IsLoaded() { + ui.Subtle("Daemon is not running") + cmd.SilenceUsage = true + return ui.ErrAlreadyPrinted + } + + return ui.Step("Restarting pv daemon...", func() (string, error) { + if err := daemon.Restart(); err != nil { + return "", fmt.Errorf("cannot restart daemon: %w", err) + } + return "Daemon restarted", nil + }) + }, +} + func init() { rootCmd.AddCommand(daemonEnableCmd) rootCmd.AddCommand(daemonDisableCmd) + rootCmd.AddCommand(daemonRestartCmd) } diff --git a/cmd/restart.go b/cmd/restart.go index 67316e8..c617850 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -16,19 +16,9 @@ var restartCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { fmt.Fprintln(os.Stderr) - // Daemon mode — use launchctl kickstart for atomic restart. + // Daemon mode — delegate to daemon:restart. if daemon.IsLoaded() { - if err := ui.Step("Restarting pv daemon...", func() (string, error) { - if err := daemon.Restart(); err != nil { - return "", fmt.Errorf("cannot restart daemon: %w", err) - } - return "pv restarted", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil + return daemonRestartCmd.RunE(daemonRestartCmd, nil) } // Foreground mode — reload config via admin API. From 2c4acc914a705f31b7153d1ff5eef6bd043f5c7a Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 17:49:51 -0500 Subject: [PATCH 04/16] Fix e2e tests: composer.phar moved to internal/bin/ Update three e2e scripts that checked ~/.pv/data/composer.phar to use the new path ~/.pv/internal/bin/composer.phar. --- scripts/e2e/composer.sh | 4 ++-- scripts/e2e/diagnostics.sh | 2 +- scripts/e2e/verify-install.sh | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/e2e/composer.sh b/scripts/e2e/composer.sh index c549b6c..49a9e0c 100755 --- a/scripts/e2e/composer.sh +++ b/scripts/e2e/composer.sh @@ -11,8 +11,8 @@ ls -la ~/.pv/bin/composer test -x ~/.pv/bin/composer || { echo "FAIL: composer shim not executable"; exit 1; } echo "==> Verify composer.phar exists" -ls -la ~/.pv/data/composer.phar -test -f ~/.pv/data/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } +ls -la ~/.pv/internal/bin/composer.phar +test -f ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } # ── 2. Verify COMPOSER_HOME isolation ────────────────────────────────── echo "==> Verify COMPOSER_HOME points to ~/.pv/composer" diff --git a/scripts/e2e/diagnostics.sh b/scripts/e2e/diagnostics.sh index efd1149..29f0dd1 100755 --- a/scripts/e2e/diagnostics.sh +++ b/scripts/e2e/diagnostics.sh @@ -50,7 +50,7 @@ echo "==> composer dir" ls -laR ~/.pv/composer/ 2>/dev/null || echo "(no composer dir)" echo "" echo "==> composer.phar" -ls -la ~/.pv/data/composer.phar 2>/dev/null || echo "(no composer.phar)" +ls -la ~/.pv/internal/bin/composer.phar 2>/dev/null || echo "(no composer.phar)" echo "" echo "==> composer shim contents" cat ~/.pv/bin/composer 2>/dev/null || echo "(no composer shim)" diff --git a/scripts/e2e/verify-install.sh b/scripts/e2e/verify-install.sh index f436e1a..3c99402 100755 --- a/scripts/e2e/verify-install.sh +++ b/scripts/e2e/verify-install.sh @@ -31,7 +31,7 @@ cat /etc/resolver/test echo "==> Verify composer shim and phar" test -x ~/.pv/bin/composer || { echo "FAIL: composer shim not executable"; exit 1; } -test -f ~/.pv/data/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } +test -f ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } echo "==> Verify composer directories" test -d ~/.pv/composer || { echo "FAIL: ~/.pv/composer not created"; exit 1; } From 1cb84cc4520c97e2f12494b5f734b7f8570ad9b2 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 18:08:01 -0500 Subject: [PATCH 05/16] Propagate Expose/Unexpose/Stop/Delete errors, fix update exit code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Stop discarding errors from tools.Expose() and tools.Unexpose() in 7 locations across install/update/uninstall commands - Handle colima Stop/Delete errors properly — don't remove binary if VM can't be stopped - Log per-tool errors in pv uninstall orchestrator - Track failures in pv update and return non-zero exit code --- cmd/colima_uninstall.go | 13 ++++++++++--- cmd/composer_uninstall.go | 5 ++++- cmd/mago_uninstall.go | 5 ++++- cmd/php_install.go | 4 +++- cmd/php_uninstall.go | 4 +++- cmd/php_update.go | 4 +++- cmd/uninstall.go | 16 ++++++++++++---- cmd/update.go | 11 +++++++++++ 8 files changed, 50 insertions(+), 12 deletions(-) diff --git a/cmd/colima_uninstall.go b/cmd/colima_uninstall.go index 117828a..dc80db0 100644 --- a/cmd/colima_uninstall.go +++ b/cmd/colima_uninstall.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/prvious/pv/internal/colima" @@ -21,8 +22,12 @@ var colimaUninstallCmd = &cobra.Command{ return ui.Step("Removing Colima...", func() (string, error) { if colima.IsRunning() { - _ = colima.Stop() - _ = colima.Delete() + if err := colima.Stop(); err != nil { + return "", fmt.Errorf("cannot stop Colima VM (stop it manually before uninstalling): %w", err) + } + if err := colima.Delete(); err != nil { + return "", fmt.Errorf("cannot delete Colima VM: %w", err) + } } if err := os.Remove(config.ColimaPath()); err != nil && !os.IsNotExist(err) { @@ -31,7 +36,9 @@ var colimaUninstallCmd = &cobra.Command{ t := tools.Get("colima") if t != nil { - _ = tools.Unexpose(t) + if err := tools.Unexpose(t); err != nil { + return "", fmt.Errorf("cannot unexpose colima: %w", err) + } } return "Colima removed", nil diff --git a/cmd/composer_uninstall.go b/cmd/composer_uninstall.go index 576e838..361a95d 100644 --- a/cmd/composer_uninstall.go +++ b/cmd/composer_uninstall.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/prvious/pv/internal/config" @@ -16,7 +17,9 @@ var composerUninstallCmd = &cobra.Command{ return ui.Step("Removing Composer...", func() (string, error) { t := tools.Get("composer") if t != nil { - _ = tools.Unexpose(t) + if err := tools.Unexpose(t); err != nil { + return "", fmt.Errorf("cannot unexpose composer: %w", err) + } } if err := os.Remove(config.ComposerPharPath()); err != nil && !os.IsNotExist(err) { diff --git a/cmd/mago_uninstall.go b/cmd/mago_uninstall.go index 1849014..2123825 100644 --- a/cmd/mago_uninstall.go +++ b/cmd/mago_uninstall.go @@ -1,6 +1,7 @@ package cmd import ( + "fmt" "os" "github.com/prvious/pv/internal/config" @@ -16,7 +17,9 @@ var magoUninstallCmd = &cobra.Command{ return ui.Step("Removing Mago...", func() (string, error) { t := tools.Get("mago") if t != nil { - _ = tools.Unexpose(t) + if err := tools.Unexpose(t); err != nil { + return "", fmt.Errorf("cannot unexpose mago: %w", err) + } } if err := os.Remove(config.MagoPath()); err != nil && !os.IsNotExist(err) { diff --git a/cmd/php_install.go b/cmd/php_install.go index 71c5297..c5c010f 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -54,7 +54,9 @@ var phpInstallCmd = &cobra.Command{ for _, name := range []string{"php", "frankenphp"} { t := tools.Get(name) if t != nil && t.AutoExpose { - _ = tools.Expose(t) + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose %s: %w", name, err) + } } } diff --git a/cmd/php_uninstall.go b/cmd/php_uninstall.go index d68253b..951dea9 100644 --- a/cmd/php_uninstall.go +++ b/cmd/php_uninstall.go @@ -18,7 +18,9 @@ var phpUninstallCmd = &cobra.Command{ for _, name := range []string{"php", "frankenphp"} { t := tools.Get(name) if t != nil { - _ = tools.Unexpose(t) + if err := tools.Unexpose(t); err != nil { + return "", fmt.Errorf("cannot unexpose %s: %w", name, err) + } } } diff --git a/cmd/php_update.go b/cmd/php_update.go index ddf7f5b..8102257 100644 --- a/cmd/php_update.go +++ b/cmd/php_update.go @@ -41,7 +41,9 @@ var phpUpdateCmd = &cobra.Command{ for _, name := range []string{"php", "frankenphp"} { t := tools.Get(name) if t != nil && tools.IsExposed(t) { - _ = tools.Expose(t) + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot re-expose %s: %w", name, err) + } } } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 7355061..399450b 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -78,10 +78,18 @@ var uninstallCmd = &cobra.Command{ tld := settings.TLD // Uninstall tools (each cleans up its own binary + PATH entry). - _ = colimaUninstallCmd.RunE(colimaUninstallCmd, nil) - _ = phpUninstallCmd.RunE(phpUninstallCmd, nil) - _ = magoUninstallCmd.RunE(magoUninstallCmd, nil) - _ = composerUninstallCmd.RunE(composerUninstallCmd, nil) + if err := colimaUninstallCmd.RunE(colimaUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Colima uninstall failed: %v\n", ui.Red.Render("!"), err) + } + if err := phpUninstallCmd.RunE(phpUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s PHP uninstall failed: %v\n", ui.Red.Render("!"), err) + } + if err := magoUninstallCmd.RunE(magoUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Mago uninstall failed: %v\n", ui.Red.Render("!"), err) + } + if err := composerUninstallCmd.RunE(composerUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Composer uninstall failed: %v\n", ui.Red.Render("!"), err) + } // Stop services. if err := ui.Step("Stopping services...", func() (string, error) { diff --git a/cmd/update.go b/cmd/update.go index a30b22a..d775fc1 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "os" + "strings" "syscall" "time" @@ -40,26 +41,36 @@ var updateCmd = &cobra.Command{ } // Step 2: Update tools. + var failures []string + if err := phpUpdateCmd.RunE(phpUpdateCmd, nil); err != nil { fmt.Fprintf(os.Stderr, " %s PHP update failed: %v\n", ui.Red.Render("!"), err) + failures = append(failures, "PHP") } if err := magoUpdateCmd.RunE(magoUpdateCmd, nil); err != nil { fmt.Fprintf(os.Stderr, " %s Mago update failed: %v\n", ui.Red.Render("!"), err) + failures = append(failures, "Mago") } if err := composerUpdateCmd.RunE(composerUpdateCmd, nil); err != nil { fmt.Fprintf(os.Stderr, " %s Composer update failed: %v\n", ui.Red.Render("!"), err) + failures = append(failures, "Composer") } if colima.IsInstalled() { if err := colimaUpdateCmd.RunE(colimaUpdateCmd, nil); err != nil { fmt.Fprintf(os.Stderr, " %s Colima update failed: %v\n", ui.Red.Render("!"), err) + failures = append(failures, "Colima") } } ui.Footer(start, "") + if len(failures) > 0 { + return fmt.Errorf("some updates failed: %s", strings.Join(failures, ", ")) + } + return nil }, } From c0bf17aad0653a01e0778495ae41393fe277d246 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 18:10:40 -0500 Subject: [PATCH 06/16] Harden tool registry: unexport All, add MustGet, derive Name from key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename All to unexported registry, derive Tool.Name from map key in init() so they can never diverge - Add MustGet(name) that panics on unknown tools — use for compile-time constant names throughout cmd/ - Add TestRegistryIntegrity: validates DisplayName, InternalPath, ExposureShim implies WriteShim - Add TestMustGet: verifies panic on unknown tools - Handle os.Remove error before symlink creation in Expose() - Replace all tools.Get with tools.MustGet where names are constants, remove redundant nil guards --- cmd/colima_install.go | 4 ++-- cmd/colima_path.go | 2 +- cmd/colima_uninstall.go | 7 ++----- cmd/colima_update.go | 4 ++-- cmd/composer_install.go | 4 ++-- cmd/composer_path.go | 2 +- cmd/composer_uninstall.go | 7 ++----- cmd/composer_update.go | 4 ++-- cmd/mago_install.go | 4 ++-- cmd/mago_path.go | 2 +- cmd/mago_uninstall.go | 7 ++----- cmd/mago_update.go | 4 ++-- cmd/php_install.go | 4 ++-- cmd/php_path.go | 4 ++-- cmd/php_uninstall.go | 7 ++----- cmd/php_update.go | 4 ++-- internal/tools/tool.go | 38 +++++++++++++++++++++++++------------ internal/tools/tool_test.go | 37 ++++++++++++++++++++++++++++++++++-- 18 files changed, 90 insertions(+), 55 deletions(-) diff --git a/cmd/colima_install.go b/cmd/colima_install.go index 8a1cbdd..4a693c4 100644 --- a/cmd/colima_install.go +++ b/cmd/colima_install.go @@ -20,8 +20,8 @@ var colimaInstallCmd = &cobra.Command{ } // Expose (no-op for colima since AutoExpose=false). - t := tools.Get("colima") - if t != nil && t.AutoExpose { + t := tools.MustGet("colima") + if t.AutoExpose { if err := tools.Expose(t); err != nil { return fmt.Errorf("cannot expose Colima: %w", err) } diff --git a/cmd/colima_path.go b/cmd/colima_path.go index 8c34d46..2972b8d 100644 --- a/cmd/colima_path.go +++ b/cmd/colima_path.go @@ -14,7 +14,7 @@ var colimaPathCmd = &cobra.Command{ Use: "colima:path", Short: "Expose or remove Colima from PATH", RunE: func(cmd *cobra.Command, args []string) error { - t := tools.Get("colima") + t := tools.MustGet("colima") if colimaPathRemove { if err := tools.Unexpose(t); err != nil { diff --git a/cmd/colima_uninstall.go b/cmd/colima_uninstall.go index dc80db0..4d23fc1 100644 --- a/cmd/colima_uninstall.go +++ b/cmd/colima_uninstall.go @@ -34,11 +34,8 @@ var colimaUninstallCmd = &cobra.Command{ return "", err } - t := tools.Get("colima") - if t != nil { - if err := tools.Unexpose(t); err != nil { - return "", fmt.Errorf("cannot unexpose colima: %w", err) - } + if err := tools.Unexpose(tools.MustGet("colima")); err != nil { + return "", fmt.Errorf("cannot unexpose colima: %w", err) } return "Colima removed", nil diff --git a/cmd/colima_update.go b/cmd/colima_update.go index 0af9ed7..52a206e 100644 --- a/cmd/colima_update.go +++ b/cmd/colima_update.go @@ -27,8 +27,8 @@ var colimaUpdateCmd = &cobra.Command{ } // Re-expose if already on PATH. - t := tools.Get("colima") - if t != nil && tools.IsExposed(t) { + t := tools.MustGet("colima") + if tools.IsExposed(t) { if err := tools.Expose(t); err != nil { return "", fmt.Errorf("cannot expose Colima: %w", err) } diff --git a/cmd/composer_install.go b/cmd/composer_install.go index 3bd7abb..598932a 100644 --- a/cmd/composer_install.go +++ b/cmd/composer_install.go @@ -20,8 +20,8 @@ var composerInstallCmd = &cobra.Command{ } // Expose to PATH. - t := tools.Get("composer") - if t != nil && t.AutoExpose { + t := tools.MustGet("composer") + if t.AutoExpose { if err := tools.Expose(t); err != nil { return fmt.Errorf("cannot expose Composer: %w", err) } diff --git a/cmd/composer_path.go b/cmd/composer_path.go index 5ac1e68..64b7441 100644 --- a/cmd/composer_path.go +++ b/cmd/composer_path.go @@ -14,7 +14,7 @@ var composerPathCmd = &cobra.Command{ Use: "composer:path", Short: "Expose or remove Composer from PATH", RunE: func(cmd *cobra.Command, args []string) error { - t := tools.Get("composer") + t := tools.MustGet("composer") if composerPathRemove { if err := tools.Unexpose(t); err != nil { diff --git a/cmd/composer_uninstall.go b/cmd/composer_uninstall.go index 361a95d..d0e5004 100644 --- a/cmd/composer_uninstall.go +++ b/cmd/composer_uninstall.go @@ -15,11 +15,8 @@ var composerUninstallCmd = &cobra.Command{ Short: "Remove Composer PHAR, PATH entry, and global packages", RunE: func(cmd *cobra.Command, args []string) error { return ui.Step("Removing Composer...", func() (string, error) { - t := tools.Get("composer") - if t != nil { - if err := tools.Unexpose(t); err != nil { - return "", fmt.Errorf("cannot unexpose composer: %w", err) - } + if err := tools.Unexpose(tools.MustGet("composer")); err != nil { + return "", fmt.Errorf("cannot unexpose composer: %w", err) } if err := os.Remove(config.ComposerPharPath()); err != nil && !os.IsNotExist(err) { diff --git a/cmd/composer_update.go b/cmd/composer_update.go index 4eb9d44..54bf6c9 100644 --- a/cmd/composer_update.go +++ b/cmd/composer_update.go @@ -37,8 +37,8 @@ var composerUpdateCmd = &cobra.Command{ return "", fmt.Errorf("cannot save versions: %w", err) } - t := tools.Get("composer") - if t != nil && tools.IsExposed(t) { + t := tools.MustGet("composer") + if tools.IsExposed(t) { if err := tools.Expose(t); err != nil { return "", fmt.Errorf("cannot expose Composer: %w", err) } diff --git a/cmd/mago_install.go b/cmd/mago_install.go index 1a7abff..bb88dd9 100644 --- a/cmd/mago_install.go +++ b/cmd/mago_install.go @@ -20,8 +20,8 @@ var magoInstallCmd = &cobra.Command{ } // Expose to PATH. - t := tools.Get("mago") - if t != nil && t.AutoExpose { + t := tools.MustGet("mago") + if t.AutoExpose { if err := tools.Expose(t); err != nil { return fmt.Errorf("cannot expose Mago: %w", err) } diff --git a/cmd/mago_path.go b/cmd/mago_path.go index 3277c21..0eca6ca 100644 --- a/cmd/mago_path.go +++ b/cmd/mago_path.go @@ -14,7 +14,7 @@ var magoPathCmd = &cobra.Command{ Use: "mago:path", Short: "Expose or remove Mago from PATH", RunE: func(cmd *cobra.Command, args []string) error { - t := tools.Get("mago") + t := tools.MustGet("mago") if magoPathRemove { if err := tools.Unexpose(t); err != nil { diff --git a/cmd/mago_uninstall.go b/cmd/mago_uninstall.go index 2123825..71942c3 100644 --- a/cmd/mago_uninstall.go +++ b/cmd/mago_uninstall.go @@ -15,11 +15,8 @@ var magoUninstallCmd = &cobra.Command{ Short: "Remove Mago binary and PATH entry", RunE: func(cmd *cobra.Command, args []string) error { return ui.Step("Removing Mago...", func() (string, error) { - t := tools.Get("mago") - if t != nil { - if err := tools.Unexpose(t); err != nil { - return "", fmt.Errorf("cannot unexpose mago: %w", err) - } + if err := tools.Unexpose(tools.MustGet("mago")); err != nil { + return "", fmt.Errorf("cannot unexpose mago: %w", err) } if err := os.Remove(config.MagoPath()); err != nil && !os.IsNotExist(err) { diff --git a/cmd/mago_update.go b/cmd/mago_update.go index 2a05bb2..72e1b07 100644 --- a/cmd/mago_update.go +++ b/cmd/mago_update.go @@ -48,8 +48,8 @@ var magoUpdateCmd = &cobra.Command{ return "", fmt.Errorf("cannot save versions: %w", err) } - t := tools.Get("mago") - if t != nil && tools.IsExposed(t) { + t := tools.MustGet("mago") + if tools.IsExposed(t) { if err := tools.Expose(t); err != nil { return "", fmt.Errorf("cannot expose Mago: %w", err) } diff --git a/cmd/php_install.go b/cmd/php_install.go index c5c010f..2baaab0 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -52,8 +52,8 @@ var phpInstallCmd = &cobra.Command{ // Expose PHP and FrankenPHP to PATH. for _, name := range []string{"php", "frankenphp"} { - t := tools.Get(name) - if t != nil && t.AutoExpose { + t := tools.MustGet(name) + if t.AutoExpose { if err := tools.Expose(t); err != nil { return fmt.Errorf("cannot expose %s: %w", name, err) } diff --git a/cmd/php_path.go b/cmd/php_path.go index a925278..dc7a810 100644 --- a/cmd/php_path.go +++ b/cmd/php_path.go @@ -14,8 +14,8 @@ var phpPathCmd = &cobra.Command{ Use: "php:path", Short: "Expose or remove PHP and FrankenPHP from PATH", RunE: func(cmd *cobra.Command, args []string) error { - php := tools.Get("php") - fp := tools.Get("frankenphp") + php := tools.MustGet("php") + fp := tools.MustGet("frankenphp") if phpPathRemove { if err := tools.Unexpose(php); err != nil { diff --git a/cmd/php_uninstall.go b/cmd/php_uninstall.go index 951dea9..ca1dd98 100644 --- a/cmd/php_uninstall.go +++ b/cmd/php_uninstall.go @@ -16,11 +16,8 @@ var phpUninstallCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { return ui.Step("Removing PHP...", func() (string, error) { for _, name := range []string{"php", "frankenphp"} { - t := tools.Get(name) - if t != nil { - if err := tools.Unexpose(t); err != nil { - return "", fmt.Errorf("cannot unexpose %s: %w", name, err) - } + if err := tools.Unexpose(tools.MustGet(name)); err != nil { + return "", fmt.Errorf("cannot unexpose %s: %w", name, err) } } diff --git a/cmd/php_update.go b/cmd/php_update.go index 8102257..e88b74c 100644 --- a/cmd/php_update.go +++ b/cmd/php_update.go @@ -39,8 +39,8 @@ var phpUpdateCmd = &cobra.Command{ // Re-expose only if already on PATH. for _, name := range []string{"php", "frankenphp"} { - t := tools.Get(name) - if t != nil && tools.IsExposed(t) { + t := tools.MustGet(name) + if tools.IsExposed(t) { if err := tools.Expose(t); err != nil { return fmt.Errorf("cannot re-expose %s: %w", name, err) } diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 6ec08eb..13ccfa8 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -20,7 +20,7 @@ const ( // Tool describes a managed binary. type Tool struct { - Name string + Name string // set automatically from registry key DisplayName string AutoExpose bool // :install auto-calls :path Exposure ExposureType @@ -40,10 +40,9 @@ func globalPHPVersion() string { return s.GlobalPHP } -// Registry of all managed tools, keyed by name. -var All = map[string]*Tool{ +// registry of all managed tools, keyed by name. +var registry = map[string]*Tool{ "php": { - Name: "php", DisplayName: "PHP", AutoExpose: true, Exposure: ExposureShim, @@ -53,7 +52,6 @@ var All = map[string]*Tool{ WriteShim: writePhpShim, }, "frankenphp": { - Name: "frankenphp", DisplayName: "FrankenPHP", AutoExpose: true, Exposure: ExposureSymlink, @@ -62,7 +60,6 @@ var All = map[string]*Tool{ }, }, "composer": { - Name: "composer", DisplayName: "Composer", AutoExpose: true, Exposure: ExposureShim, @@ -72,7 +69,6 @@ var All = map[string]*Tool{ WriteShim: writeComposerShim, }, "mago": { - Name: "mago", DisplayName: "Mago", AutoExpose: true, Exposure: ExposureSymlink, @@ -81,7 +77,6 @@ var All = map[string]*Tool{ }, }, "colima": { - Name: "colima", DisplayName: "Colima", AutoExpose: false, Exposure: ExposureSymlink, @@ -91,15 +86,32 @@ var All = map[string]*Tool{ }, } +func init() { + // Derive Name from map key so they can never diverge. + for name, t := range registry { + t.Name = name + } +} + // Get returns the tool with the given name, or nil. func Get(name string) *Tool { - return All[name] + return registry[name] +} + +// MustGet returns the tool with the given name, or panics. +// Use for compile-time constant names where a missing tool is a programming error. +func MustGet(name string) *Tool { + t := registry[name] + if t == nil { + panic(fmt.Sprintf("tools: unknown tool %q", name)) + } + return t } // List returns all tools sorted by name. func List() []*Tool { var out []*Tool - for _, t := range All { + for _, t := range registry { out = append(out, t) } sort.Slice(out, func(i, j int) bool { @@ -123,7 +135,9 @@ func Expose(t *Tool) error { case ExposureSymlink: target := t.InternalPath() linkPath := filepath.Join(binDir, t.Name) - os.Remove(linkPath) // remove existing + if err := os.Remove(linkPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("cannot remove existing %s: %w", linkPath, err) + } if err := os.Symlink(target, linkPath); err != nil { return fmt.Errorf("cannot create symlink %s -> %s: %w", linkPath, target, err) } @@ -154,7 +168,7 @@ func IsExposed(t *Tool) bool { // ExposeAll exposes all tools that have AutoExpose=true. func ExposeAll() error { - for _, t := range All { + for _, t := range registry { if !t.AutoExpose { continue } diff --git a/internal/tools/tool_test.go b/internal/tools/tool_test.go index 085520c..574535e 100644 --- a/internal/tools/tool_test.go +++ b/internal/tools/tool_test.go @@ -191,8 +191,8 @@ func TestGet(t *testing.T) { func TestList(t *testing.T) { list := List() - if len(list) != len(All) { - t.Errorf("List() returned %d tools, want %d", len(list), len(All)) + if len(list) != len(registry) { + t.Errorf("List() returned %d tools, want %d", len(list), len(registry)) } // Verify sorted. for i := 1; i < len(list); i++ { @@ -201,3 +201,36 @@ func TestList(t *testing.T) { } } } + +func TestMustGet(t *testing.T) { + // Known tool should not panic. + tool := MustGet("php") + if tool == nil { + t.Error("MustGet(php) = nil") + } + + // Unknown tool should panic. + defer func() { + if r := recover(); r == nil { + t.Error("MustGet(unknown) did not panic") + } + }() + MustGet("nonexistent") +} + +func TestRegistryIntegrity(t *testing.T) { + for name, tool := range registry { + if tool.Name != name { + t.Errorf("tool %q: Name=%q does not match registry key", name, tool.Name) + } + if tool.DisplayName == "" { + t.Errorf("tool %q: DisplayName is empty", name) + } + if tool.InternalPath == nil { + t.Errorf("tool %q: InternalPath is nil", name) + } + if tool.Exposure == ExposureShim && tool.WriteShim == nil { + t.Errorf("tool %q: ExposureShim requires WriteShim func", name) + } + } +} From 8904fd7c987fdb679254ba3fafa27a492c4ce6e6 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 18:11:26 -0500 Subject: [PATCH 07/16] Delegate :update commands to :download RunE mago:update, composer:update, and colima:update now delegate their download logic to the corresponding :download command instead of inlining it. Only version checking and re-expose logic remains. --- cmd/colima_update.go | 26 +++++++++++--------------- cmd/composer_update.go | 39 +++++++++------------------------------ cmd/mago_update.go | 37 +++++++++---------------------------- 3 files changed, 29 insertions(+), 73 deletions(-) diff --git a/cmd/colima_update.go b/cmd/colima_update.go index 52a206e..f23bbdc 100644 --- a/cmd/colima_update.go +++ b/cmd/colima_update.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "net/http" "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/tools" @@ -19,23 +18,20 @@ var colimaUpdateCmd = &cobra.Command{ return nil } - client := &http.Client{} - - return ui.StepProgress("Updating Colima...", func(progress func(written, total int64)) (string, error) { - if err := colima.Install(client, progress); err != nil { - return "", fmt.Errorf("cannot download Colima: %w", err) - } + // Delegate download to :download. + if err := colimaDownloadCmd.RunE(colimaDownloadCmd, nil); err != nil { + return err + } - // Re-expose if already on PATH. - t := tools.MustGet("colima") - if tools.IsExposed(t) { - if err := tools.Expose(t); err != nil { - return "", fmt.Errorf("cannot expose Colima: %w", err) - } + // Re-expose if already on PATH. + t := tools.MustGet("colima") + if tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Colima: %w", err) } + } - return "Colima updated", nil - }) + return nil }, } diff --git a/cmd/composer_update.go b/cmd/composer_update.go index 54bf6c9..f500316 100644 --- a/cmd/composer_update.go +++ b/cmd/composer_update.go @@ -2,12 +2,8 @@ package cmd import ( "fmt" - "net/http" - "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/tools" - "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -15,37 +11,20 @@ var composerUpdateCmd = &cobra.Command{ Use: "composer:update", Short: "Update Composer to the latest version", RunE: func(cmd *cobra.Command, args []string) error { - client := &http.Client{} - - if err := config.EnsureDirs(); err != nil { + // Delegate download to :download (Composer always re-downloads). + if err := composerDownloadCmd.RunE(composerDownloadCmd, nil); err != nil { return err } - // Composer always re-downloads (no version comparison). - return ui.StepProgress("Updating Composer...", func(progress func(written, total int64)) (string, error) { - vs, err := binaries.LoadVersions() - if err != nil { - return "", fmt.Errorf("cannot load version state: %w", err) - } - - if err := binaries.InstallBinaryProgress(client, binaries.Composer, "latest", progress); err != nil { - return "", fmt.Errorf("cannot download Composer: %w", err) - } - - vs.Set("composer", "latest") - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save versions: %w", err) - } - - t := tools.MustGet("composer") - if tools.IsExposed(t) { - if err := tools.Expose(t); err != nil { - return "", fmt.Errorf("cannot expose Composer: %w", err) - } + // Re-expose if already on PATH. + t := tools.MustGet("composer") + if tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Composer: %w", err) } + } - return "Composer updated", nil - }) + return nil }, } diff --git a/cmd/mago_update.go b/cmd/mago_update.go index 72e1b07..6e03b9a 100644 --- a/cmd/mago_update.go +++ b/cmd/mago_update.go @@ -5,7 +5,6 @@ import ( "net/http" "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" @@ -17,10 +16,6 @@ var magoUpdateCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} - if err := config.EnsureDirs(); err != nil { - return err - } - vs, err := binaries.LoadVersions() if err != nil { return fmt.Errorf("cannot load version state: %w", err) @@ -36,31 +31,17 @@ var magoUpdateCmd = &cobra.Command{ return nil } - current := vs.Get("mago") - - if err := ui.StepProgress("Updating Mago...", func(progress func(written, total int64)) (string, error) { - if err := binaries.InstallBinaryProgress(client, binaries.Mago, latest, progress); err != nil { - return "", fmt.Errorf("cannot download Mago: %w", err) - } - - vs.Set("mago", latest) - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save versions: %w", err) - } - - t := tools.MustGet("mago") - if tools.IsExposed(t) { - if err := tools.Expose(t); err != nil { - return "", fmt.Errorf("cannot expose Mago: %w", err) - } - } + // Delegate download to :download. + if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + return err + } - if current != "" { - return fmt.Sprintf("Mago %s -> %s", current, latest), nil + // Re-expose if already on PATH. + t := tools.MustGet("mago") + if tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose Mago: %w", err) } - return fmt.Sprintf("Mago %s", latest), nil - }); err != nil { - return err } return nil From 874b5959a76c9624058ab0dba8e2d57a997dbdfe Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 18:12:33 -0500 Subject: [PATCH 08/16] Add selfupdate tests, fix comment and error context, simplify platformString - Add tests for NeedsUpdate (newer, same, dev, empty, v-prefix) - Add tests for downloadURL and platformString - Extract githubAPIURL as testable variable - Wrap bare io.ReadAll error with context - Remove no-op identity switch in platformString() - Fix misleading "unreachable" comment in update.go --- cmd/update.go | 2 +- internal/selfupdate/selfupdate.go | 16 ++- internal/selfupdate/selfupdate_test.go | 129 +++++++++++++++++++++++++ 3 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 internal/selfupdate/selfupdate_test.go diff --git a/cmd/update.go b/cmd/update.go index d775fc1..327d6a6 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -36,7 +36,7 @@ var updateCmd = &cobra.Command{ fmt.Fprintf(os.Stderr, " %s pv self-update failed: %v\n", ui.Red.Render("!"), err) } if reexeced { - return nil // unreachable — syscall.Exec replaces the process + return nil // reached only if syscall.Exec failed (error already printed) } } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index a1798f4..76f5b4c 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -95,8 +95,11 @@ func Update(client *http.Client, version string, progress func(written, total in return execPath, nil } +// githubAPIURL is the base URL for GitHub API calls. Overridable in tests. +var githubAPIURL = "https://api.github.com/repos/prvious/pv/releases/" + func fetchLatestVersion(client *http.Client) (string, error) { - url := "https://api.github.com/repos/prvious/pv/releases/latest" + url := githubAPIURL + "latest" req, err := http.NewRequest("GET", url, nil) if err != nil { return "", err @@ -115,7 +118,7 @@ func fetchLatestVersion(client *http.Client) (string, error) { body, err := io.ReadAll(resp.Body) if err != nil { - return "", err + return "", fmt.Errorf("cannot read GitHub API response: %w", err) } var release struct { @@ -129,14 +132,7 @@ func fetchLatestVersion(client *http.Client) (string, error) { } func platformString() string { - arch := runtime.GOARCH - switch arch { - case "amd64": - arch = "amd64" - case "arm64": - arch = "arm64" - } - return fmt.Sprintf("%s-%s", runtime.GOOS, arch) + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) } func downloadURL(version string) string { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go new file mode 100644 index 0000000..b79e0d2 --- /dev/null +++ b/internal/selfupdate/selfupdate_test.go @@ -0,0 +1,129 @@ +package selfupdate + +import ( + "fmt" + "net/http" + "net/http/httptest" + "runtime" + "testing" +) + +func TestNeedsUpdate_NewerVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v2.0.0"}`) + })) + defer srv.Close() + + origURL := githubAPIURL + githubAPIURL = srv.URL + "/" + defer func() { githubAPIURL = origURL }() + + latest, needed, err := NeedsUpdate(srv.Client(), "1.0.0") + if err != nil { + t.Fatalf("NeedsUpdate() error = %v", err) + } + if latest != "2.0.0" { + t.Errorf("latest = %q, want %q", latest, "2.0.0") + } + if !needed { + t.Error("expected update needed") + } +} + +func TestNeedsUpdate_SameVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v1.0.0"}`) + })) + defer srv.Close() + + origURL := githubAPIURL + githubAPIURL = srv.URL + "/" + defer func() { githubAPIURL = origURL }() + + _, needed, err := NeedsUpdate(srv.Client(), "1.0.0") + if err != nil { + t.Fatalf("NeedsUpdate() error = %v", err) + } + if needed { + t.Error("expected no update needed for same version") + } +} + +func TestNeedsUpdate_DevVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v2.0.0"}`) + })) + defer srv.Close() + + origURL := githubAPIURL + githubAPIURL = srv.URL + "/" + defer func() { githubAPIURL = origURL }() + + _, needed, err := NeedsUpdate(srv.Client(), "dev") + if err != nil { + t.Fatalf("NeedsUpdate() error = %v", err) + } + if needed { + t.Error("expected no update for dev version") + } +} + +func TestNeedsUpdate_EmptyVersion(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v2.0.0"}`) + })) + defer srv.Close() + + origURL := githubAPIURL + githubAPIURL = srv.URL + "/" + defer func() { githubAPIURL = origURL }() + + _, needed, err := NeedsUpdate(srv.Client(), "") + if err != nil { + t.Fatalf("NeedsUpdate() error = %v", err) + } + if needed { + t.Error("expected no update for empty version") + } +} + +func TestNeedsUpdate_VPrefixNormalization(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"tag_name":"v1.0.0"}`) + })) + defer srv.Close() + + origURL := githubAPIURL + githubAPIURL = srv.URL + "/" + defer func() { githubAPIURL = origURL }() + + _, needed, err := NeedsUpdate(srv.Client(), "v1.0.0") + if err != nil { + t.Fatalf("NeedsUpdate() error = %v", err) + } + if needed { + t.Error("v1.0.0 should match v1.0.0 after normalization") + } +} + +func TestDownloadURL(t *testing.T) { + url := downloadURL("1.2.3") + expected := fmt.Sprintf("https://github.com/prvious/pv/releases/download/v1.2.3/pv-%s-%s", runtime.GOOS, runtime.GOARCH) + if url != expected { + t.Errorf("downloadURL(1.2.3) = %q, want %q", url, expected) + } + + // Should strip v prefix. + url2 := downloadURL("v1.2.3") + if url2 != expected { + t.Errorf("downloadURL(v1.2.3) = %q, want %q", url2, expected) + } +} + +func TestPlatformString(t *testing.T) { + result := platformString() + expected := fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) + if result != expected { + t.Errorf("platformString() = %q, want %q", result, expected) + } +} From 18555ec0590bd6fedda510d96efc0394186f323d Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 18:13:14 -0500 Subject: [PATCH 09/16] Fix step numbering in install.go, correct README symlink paths - Renumber install.go steps 5-8 to 5-9 (was duplicate Step 5) - Show absolute paths for symlinks in README architecture diagram --- README.md | 6 +++--- cmd/install.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index ef68e67..89a4dae 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,9 @@ pv uninstall # Complete removal with guided cleanup ├── bin/ # User PATH — shims and symlinks only │ ├── php # Shim (version resolution) │ ├── composer # Shim (wraps PHAR with PHP) -│ ├── frankenphp # Symlink → ../php/{ver}/frankenphp -│ ├── mago # Symlink → ../internal/bin/mago -│ └── colima # Symlink → ../internal/bin/colima (opt-in) +│ ├── frankenphp # Symlink → ~/.pv/php/{ver}/frankenphp +│ ├── mago # Symlink → ~/.pv/internal/bin/mago +│ └── colima # Symlink → ~/.pv/internal/bin/colima (opt-in) ├── internal/bin/ # Private storage — real binaries │ ├── colima │ ├── mago diff --git a/cmd/install.go b/cmd/install.go index b38ff5a..91ebec3 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -204,7 +204,7 @@ var installCmd = &cobra.Command{ return err } - // Step 5: DNS resolver (sudo). + // Step 6: DNS resolver (sudo). if err := ui.Step("Setting up DNS resolver...", func() (string, error) { if err := setup.RunSudoResolver(installTLD); err != nil { return "", fmt.Errorf("DNS resolver setup failed: %w", err) @@ -214,7 +214,7 @@ var installCmd = &cobra.Command{ return err } - // Step 6: Trust CA certificate (sudo). + // Step 7: Trust CA certificate (sudo). if err := ui.Step("Trusting HTTPS certificate...", func() (string, error) { if err := setup.RunSudoTrustWithServer(); err != nil { return "", fmt.Errorf("CA trust failed: %w", err) @@ -224,7 +224,7 @@ var installCmd = &cobra.Command{ return err } - // Step 7: Self-test. + // Step 8: Self-test. if err := ui.Step("Running self-test...", func() (string, error) { results := setup.RunSelfTest(installTLD) var failures []string @@ -241,7 +241,7 @@ var installCmd = &cobra.Command{ return err } - // Step 8: Shell PATH. + // Step 9: Shell PATH. if err := ui.Step("Configuring shell...", func() (string, error) { shell := setup.DetectShell() configFile := setup.ShellConfigFile(shell) From 964cad752fc68df5370d1acee22ded672a5c2399 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 19:56:08 -0500 Subject: [PATCH 10/16] Unify install/setup, add --with flag, lazy-install Colima MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite `pv install` as pure orchestrator with --with flag for version overrides and services (e.g., --with="php:8.2,mago,service[redis:7]") - Non-negotiables: PHP, Composer. Mago is opt-in via --with. - Colima removed from install/setup — lazy-installed on first service:add - Fix `pv setup` gaps: add sudo, DNS resolver, CA trust, shell PATH, and actually spin up selected services - Extract shared bootstrap logic into cmd/bootstrap.go - Make php:install auto-resolve latest version when no arg given - Remove self-test (internal/setup/selftest.go) - Silence usage output on errors, use ui.ErrAlreadyPrinted for clean output - Add -f shorthand for --force - Add parseWith tests --- cmd/bootstrap.go | 99 ++++++++++ cmd/install.go | 324 ++++++++++++-------------------- cmd/install_test.go | 101 ++++++++++ cmd/php_install.go | 26 ++- cmd/service_add.go | 60 +++--- cmd/setup.go | 123 ++++++------ internal/setup/selftest.go | 120 ------------ internal/setup/selftest_test.go | 67 ------- 8 files changed, 420 insertions(+), 500 deletions(-) create mode 100644 cmd/bootstrap.go delete mode 100644 internal/setup/selftest.go delete mode 100644 internal/setup/selftest_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..ec05f05 --- /dev/null +++ b/cmd/bootstrap.go @@ -0,0 +1,99 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/prvious/pv/internal/caddy" + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/setup" + "github.com/prvious/pv/internal/ui" +) + +// acquireSudo prompts for sudo credentials upfront so password prompts +// don't interfere with spinner output during DNS/CA steps. +func acquireSudo() error { + ui.Subtle("pv needs sudo for DNS and certificate setup.") + sudoCmd := exec.Command("sudo", "-v") + sudoCmd.Stdin = os.Stdin + sudoCmd.Stdout = os.Stderr + sudoCmd.Stderr = os.Stderr + if err := sudoCmd.Run(); err != nil { + return fmt.Errorf("sudo authentication failed: %w", err) + } + fmt.Fprintln(os.Stderr) + return nil +} + +// bootstrapFinalize runs the post-install finalization steps shared by +// both `pv install` and `pv setup`: Caddyfile, registry, DNS, CA trust, shell PATH. +func bootstrapFinalize(tld string) error { + // Generate Caddyfile. + if err := ui.Step("Configuring environment...", func() (string, error) { + if err := caddy.GenerateCaddyfile(); err != nil { + return "", fmt.Errorf("cannot generate Caddyfile: %w", err) + } + + // Create empty registry if it doesn't exist. + if _, err := os.Stat(config.RegistryPath()); os.IsNotExist(err) { + reg := ®istry.Registry{} + if err := reg.Save(); err != nil { + return "", fmt.Errorf("cannot save registry: %w", err) + } + } + + return "Environment configured", nil + }); err != nil { + return err + } + + // DNS resolver (sudo). + if err := ui.Step("Setting up DNS resolver...", func() (string, error) { + if err := setup.RunSudoResolver(tld); err != nil { + return "", fmt.Errorf("DNS resolver setup failed: %w", err) + } + return "DNS resolver configured", nil + }); err != nil { + return err + } + + // Trust CA certificate (sudo). + if err := ui.Step("Trusting HTTPS certificate...", func() (string, error) { + if err := setup.RunSudoTrustWithServer(); err != nil { + return "", fmt.Errorf("CA trust failed: %w", err) + } + return "HTTPS certificate trusted", nil + }); err != nil { + return err + } + + // Shell PATH. + if err := ui.Step("Configuring shell...", func() (string, error) { + shell := setup.DetectShell() + configFile := setup.ShellConfigFile(shell) + line := setup.PathExportLine(shell) + + data, err := os.ReadFile(configFile) + if err == nil && strings.Contains(string(data), line) { + return "PATH already configured", nil + } + + f, err := os.OpenFile(configFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return "", fmt.Errorf("cannot open %s: %w", configFile, err) + } + defer f.Close() + if _, err := fmt.Fprintf(f, "\n# pv\n%s\n", line); err != nil { + return "", fmt.Errorf("cannot write to %s: %w", configFile, err) + } + + return fmt.Sprintf("Added to ~/%s", shortPath(configFile)), nil + }); err != nil { + return err + } + + return nil +} diff --git a/cmd/install.go b/cmd/install.go index 91ebec3..b918351 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,69 +2,117 @@ package cmd import ( "fmt" - "net/http" "os" - "os/exec" - "path/filepath" "strings" "time" - "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/caddy" - "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/phpenv" - "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" "github.com/prvious/pv/internal/setup" - "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) var ( - forceInstall bool - installTLD string - installPHP string - installVerbose bool + forceInstall bool + installTLD string + installWith string ) +// withSpec holds parsed --with flag values. +type withSpec struct { + phpVersion string // empty = latest + mago bool + services []serviceSpec +} + +type serviceSpec struct { + name string + version string +} + +func parseWith(raw string) (withSpec, error) { + var spec withSpec + if raw == "" { + return spec, nil + } + + for _, item := range strings.Split(raw, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + + if strings.HasPrefix(item, "service[") && strings.HasSuffix(item, "]") { + inner := item[8 : len(item)-1] + parts := strings.SplitN(inner, ":", 2) + s := serviceSpec{name: parts[0]} + if len(parts) > 1 { + s.version = parts[1] + } + if _, err := services.Lookup(s.name); err != nil { + return spec, fmt.Errorf("unknown service %q in --with (available: %s)", s.name, strings.Join(services.Available(), ", ")) + } + spec.services = append(spec.services, s) + } else { + parts := strings.SplitN(item, ":", 2) + name := parts[0] + version := "" + if len(parts) > 1 { + version = parts[1] + } + switch name { + case "php": + spec.phpVersion = version + case "mago": + spec.mago = true + default: + return spec, fmt.Errorf("unknown tool %q in --with (available: php, mago)", name) + } + } + } + return spec, nil +} + var installCmd = &cobra.Command{ Use: "install", - Short: "Non-interactive setup — installs PHP, Composer, Mago, and Colima", - Long: "Installs the core pv stack non-interactively. For an interactive setup wizard, use: pv setup", + Short: "Non-interactive setup — installs PHP, Composer, and configures the environment", + Long: `Installs the core pv stack non-interactively. For an interactive setup wizard, use: pv setup + +Non-negotiable tools (always installed): PHP, Composer +Optional tools: Mago (via --with) +Colima is installed automatically when you add your first service. + +Examples: + pv install + pv install --tld=test + pv install --with="php:8.2,mago" + pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() - // Propagate verbose flag. - binaries.Verbose = installVerbose - phpenv.Verbose = installVerbose - setup.Verbose = installVerbose - - // Print header. - ui.Header(version) + spec, err := parseWith(installWith) + if err != nil { + return err + } - // 0. Validate TLD. if err := config.ValidateTLD(installTLD); err != nil { return err } if setup.IsAlreadyInstalled() && !forceInstall { - return fmt.Errorf("pv is already installed at %s\n Run with --force to reinstall", config.PvDir()) + fmt.Fprintln(os.Stderr) + ui.Fail("pv is already installed") + ui.FailDetail("Run with --force to reinstall") + fmt.Fprintln(os.Stderr) + return ui.ErrAlreadyPrinted } - // Acquire sudo credentials upfront so password prompt doesn't - // interfere with spinner output during DNS/CA steps. - ui.Subtle("pv needs sudo for DNS and certificate setup.") - sudoCmd := exec.Command("sudo", "-v") - sudoCmd.Stdin = os.Stdin - sudoCmd.Stdout = os.Stderr - sudoCmd.Stderr = os.Stderr - if err := sudoCmd.Run(); err != nil { - return fmt.Errorf("sudo authentication failed: %w", err) - } - fmt.Fprintln(os.Stderr) + ui.Header(version) - client := &http.Client{} + if err := acquireSudo(); err != nil { + return err + } // Step 1: Check prerequisites. if err := ui.Step("Checking prerequisites...", func() (string, error) { @@ -73,202 +121,60 @@ var installCmd = &cobra.Command{ } return fmt.Sprintf("macOS %s", setup.PlatformLabel()), nil }); err != nil { - return err + return ui.ErrAlreadyPrinted } - // Step 2: Install PHP (with progress bar for large downloads). - phpVersion := installPHP - var fullPHPResult string - if err := ui.StepProgress("Installing PHP...", func(progress func(written, total int64)) (string, error) { - if phpVersion == "" { - available, err := phpenv.AvailableVersions(client) - if err != nil { - return "", fmt.Errorf("cannot detect available PHP versions: %w", err) - } - if len(available) == 0 { - return "", fmt.Errorf("no PHP versions found in releases") - } - phpVersion = available[len(available)-1] - } - - // Create directory structure first. + // Step 2: Create directory structure and save settings. + if err := ui.Step("Preparing environment...", func() (string, error) { if err := config.EnsureDirs(); err != nil { return "", fmt.Errorf("cannot create directories: %w", err) } - - // Save TLD setting. settings := &config.Settings{TLD: installTLD} if err := settings.Save(); err != nil { return "", fmt.Errorf("cannot save settings: %w", err) } - - // Install PHP version with progress tracking. - if err := phpenv.InstallProgress(client, phpVersion, progress); err != nil { - return "", fmt.Errorf("cannot install PHP %s: %w", phpVersion, err) - } - - // Set as global default. - if err := phpenv.SetGlobal(phpVersion); err != nil { - return "", fmt.Errorf("cannot set global PHP: %w", err) - } - - // Detect full version for display. - fullVersion, err := binaries.DetectPHPVersion(config.PhpVersionDir(phpVersion)) - if err != nil { - fullPHPResult = fmt.Sprintf("PHP %s (FrankenPHP + CLI)", phpVersion) - } else { - fullPHPResult = fmt.Sprintf("PHP %s (FrankenPHP + CLI)", fullVersion) - } - return fullPHPResult, nil + return "Directories created", nil }); err != nil { - return err + return ui.ErrAlreadyPrinted } - // Step 3: Install tools (Mago, Composer). - var toolVersions []string - if err := ui.Step("Installing tools...", func() (string, error) { - vs, err := binaries.LoadVersions() - if err != nil { - return "", fmt.Errorf("cannot load version state: %w", err) - } - - for _, b := range binaries.Tools() { - latest, err := binaries.FetchLatestVersion(client, b) - if err != nil { - return "", fmt.Errorf("cannot check %s version: %w", b.DisplayName, err) - } - - if err := binaries.InstallBinary(client, b, latest); err != nil { - return "", fmt.Errorf("cannot install %s: %w", b.DisplayName, err) - } - - vs.Set(b.Name, latest) - displayVersion := latest - if displayVersion == "latest" { - displayVersion = "installed" - } - toolVersions = append(toolVersions, fmt.Sprintf("%s %s", b.DisplayName, displayVersion)) - } - - // Migrate old composer.phar location. - oldComposer := filepath.Join(config.DataDir(), "composer.phar") - if _, err := os.Stat(oldComposer); err == nil { - os.Remove(oldComposer) - } - - // Expose tools (shims + symlinks). - if err := tools.ExposeAll(); err != nil { - return "", fmt.Errorf("cannot expose tools: %w", err) - } - - // Migrate existing Composer config if present. - setup.MigrateComposerConfig() - - // Save version manifest. - vs.Set("php", phpVersion) - if err := vs.Save(); err != nil { - return "", fmt.Errorf("cannot save versions: %w", err) - } - - return strings.Join(toolVersions, ", "), nil - }); err != nil { - return err - } - - // Step 4: Install Colima (container runtime for services). - if err := ui.StepProgress("Installing Colima...", func(progress func(written, total int64)) (string, error) { - if err := colima.Install(client, progress); err != nil { - return "", fmt.Errorf("cannot install Colima: %w", err) - } - return "Colima installed", nil - }); err != nil { - // Colima install failure is non-fatal — services are optional. - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("!"), ui.Muted.Render(fmt.Sprintf("Colima install skipped: %v", err))) + // Step 3: Install PHP (non-negotiable). + phpArgs := []string{} + if spec.phpVersion != "" { + phpArgs = []string{spec.phpVersion} } - - // Step 5: Configure environment. - if err := ui.Step("Configuring environment...", func() (string, error) { - // Generate Caddyfile. - if err := caddy.GenerateCaddyfile(); err != nil { - return "", fmt.Errorf("cannot generate Caddyfile: %w", err) - } - - // Create empty registry. - reg := ®istry.Registry{} - if err := reg.Save(); err != nil { - return "", fmt.Errorf("cannot save registry: %w", err) - } - - return "Environment configured", nil - }); err != nil { - return err + if err := phpInstallCmd.RunE(phpInstallCmd, phpArgs); err != nil { + return ui.ErrAlreadyPrinted } - // Step 6: DNS resolver (sudo). - if err := ui.Step("Setting up DNS resolver...", func() (string, error) { - if err := setup.RunSudoResolver(installTLD); err != nil { - return "", fmt.Errorf("DNS resolver setup failed: %w", err) - } - return "DNS resolver configured", nil - }); err != nil { - return err + // Step 4: Install Composer (non-negotiable). + if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { + return ui.ErrAlreadyPrinted } - // Step 7: Trust CA certificate (sudo). - if err := ui.Step("Trusting HTTPS certificate...", func() (string, error) { - if err := setup.RunSudoTrustWithServer(); err != nil { - return "", fmt.Errorf("CA trust failed: %w", err) + // Step 5: Install Mago (opt-in via --with). + if spec.mago { + if err := magoInstallCmd.RunE(magoInstallCmd, nil); err != nil { + return ui.ErrAlreadyPrinted } - return "HTTPS certificate trusted", nil - }); err != nil { - return err } - // Step 8: Self-test. - if err := ui.Step("Running self-test...", func() (string, error) { - results := setup.RunSelfTest(installTLD) - var failures []string - for _, r := range results { - if r.Err != nil { - failures = append(failures, fmt.Sprintf("%s: %v", r.Name, r.Err)) - } - } - if len(failures) > 0 { - return "", fmt.Errorf("self-test failures:\n %s", strings.Join(failures, "\n ")) - } - return "All checks passed", nil - }); err != nil { - return err + // Step 6: Finalize (Caddyfile, DNS, CA trust, shell PATH). + if err := bootstrapFinalize(installTLD); err != nil { + return ui.ErrAlreadyPrinted } - // Step 9: Shell PATH. - if err := ui.Step("Configuring shell...", func() (string, error) { - shell := setup.DetectShell() - configFile := setup.ShellConfigFile(shell) - line := setup.PathExportLine(shell) - - // Check if already in PATH. - data, err := os.ReadFile(configFile) - if err == nil && strings.Contains(string(data), line) { - return "PATH already configured", nil + // Step 7: Install services from --with. + for _, svc := range spec.services { + svcArgs := []string{svc.name} + if svc.version != "" { + svcArgs = append(svcArgs, svc.version) } - - // Add to config file. - f, err := os.OpenFile(configFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return "", fmt.Errorf("cannot open %s: %w", configFile, err) + if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { + fmt.Fprintf(os.Stderr, " %s Service %s failed: %v\n", ui.Red.Render("!"), svc.name, err) } - defer f.Close() - if _, err := fmt.Fprintf(f, "\n# pv\n%s\n", line); err != nil { - return "", fmt.Errorf("cannot write to %s: %w", configFile, err) - } - - return fmt.Sprintf("Added to ~/%s", shortPath(configFile)), nil - }); err != nil { - return err } - // Footer. ui.Footer(start, "https://pv.prvious.dev/docs") return nil @@ -285,9 +191,9 @@ func shortPath(path string) string { } func init() { - installCmd.Flags().BoolVar(&forceInstall, "force", false, "Reinstall even if already installed") + installCmd.Flags().BoolVarP(&forceInstall, "force", "f", false, "Reinstall even if already installed") + installCmd.SilenceUsage = true installCmd.Flags().StringVar(&installTLD, "tld", "test", "Top-level domain for local sites (e.g., test, pv-test)") - installCmd.Flags().StringVar(&installPHP, "php", "", "PHP version to install (e.g., 8.4). Auto-detects latest if omitted.") - installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Show detailed output") + installCmd.Flags().StringVar(&installWith, "with", "", `Optional tools and services (e.g., "php:8.2,mago,service[redis:7]")`) rootCmd.AddCommand(installCmd) } diff --git a/cmd/install_test.go b/cmd/install_test.go index fdbc0ba..8c948e9 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -11,6 +11,7 @@ import ( func newInstallCmd() *cobra.Command { var force bool var tld string + var with string root := &cobra.Command{Use: "pv", SilenceErrors: true, SilenceUsage: true} install := &cobra.Command{ @@ -18,11 +19,13 @@ func newInstallCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { forceInstall = force installTLD = tld + installWith = with return installCmd.RunE(cmd, args) }, } install.Flags().BoolVar(&force, "force", false, "Reinstall") install.Flags().StringVar(&tld, "tld", "test", "TLD") + install.Flags().StringVar(&with, "with", "", "Optional tools and services") root.AddCommand(install) return root } @@ -59,6 +62,104 @@ func TestInstallCmd_HasTLDFlag(t *testing.T) { } } +func TestInstallCmd_HasWithFlag(t *testing.T) { + root := newInstallCmd() + cmd, _, _ := root.Find([]string{"install"}) + flag := cmd.Flags().Lookup("with") + if flag == nil { + t.Error("--with flag not found") + } +} + +func TestParseWith_Empty(t *testing.T) { + spec, err := parseWith("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.phpVersion != "" || spec.mago || len(spec.services) != 0 { + t.Errorf("expected empty spec, got %+v", spec) + } +} + +func TestParseWith_PHPVersion(t *testing.T) { + spec, err := parseWith("php:8.2") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.phpVersion != "8.2" { + t.Errorf("phpVersion = %q, want %q", spec.phpVersion, "8.2") + } +} + +func TestParseWith_Mago(t *testing.T) { + spec, err := parseWith("mago") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !spec.mago { + t.Error("mago should be true") + } +} + +func TestParseWith_Services(t *testing.T) { + spec, err := parseWith("service[redis:7],service[mysql:8.0]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.services) != 2 { + t.Fatalf("expected 2 services, got %d", len(spec.services)) + } + if spec.services[0].name != "redis" || spec.services[0].version != "7" { + t.Errorf("service[0] = %+v, want redis:7", spec.services[0]) + } + if spec.services[1].name != "mysql" || spec.services[1].version != "8.0" { + t.Errorf("service[1] = %+v, want mysql:8.0", spec.services[1]) + } +} + +func TestParseWith_Full(t *testing.T) { + spec, err := parseWith("php:8.3,mago,service[redis:7],service[postgres:15]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.phpVersion != "8.3" { + t.Errorf("phpVersion = %q, want %q", spec.phpVersion, "8.3") + } + if !spec.mago { + t.Error("mago should be true") + } + if len(spec.services) != 2 { + t.Fatalf("expected 2 services, got %d", len(spec.services)) + } +} + +func TestParseWith_UnknownTool(t *testing.T) { + _, err := parseWith("unknown") + if err == nil { + t.Error("expected error for unknown tool") + } +} + +func TestParseWith_UnknownService(t *testing.T) { + _, err := parseWith("service[fakesvc:1]") + if err == nil { + t.Error("expected error for unknown service") + } +} + +func TestParseWith_ServiceNoVersion(t *testing.T) { + spec, err := parseWith("service[redis]") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(spec.services) != 1 { + t.Fatalf("expected 1 service, got %d", len(spec.services)) + } + if spec.services[0].name != "redis" || spec.services[0].version != "" { + t.Errorf("service = %+v, want redis with empty version", spec.services[0]) + } +} + func TestInstallCmd_AlreadyInstalled(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) diff --git a/cmd/php_install.go b/cmd/php_install.go index 2baaab0..9646133 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "net/http" "os" "regexp" @@ -14,11 +15,28 @@ import ( var validPHPVersion = regexp.MustCompile(`^\d+\.\d+$`) var phpInstallCmd = &cobra.Command{ - Use: "php:install ", - Short: "Install a PHP version (e.g., pv php:install 8.4)", - Args: cobra.ExactArgs(1), + Use: "php:install [version]", + Short: "Install a PHP version (e.g., pv php:install 8.4). Installs latest if omitted.", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] + version := "" + if len(args) > 0 { + version = args[0] + } + + // Auto-resolve latest if no version specified. + if version == "" { + client := &http.Client{} + available, err := phpenv.AvailableVersions(client) + if err != nil { + return fmt.Errorf("cannot detect available PHP versions: %w", err) + } + if len(available) == 0 { + return fmt.Errorf("no PHP versions found in releases") + } + version = available[len(available)-1] + } + if !validPHPVersion.MatchString(version) { fmt.Fprintln(os.Stderr) ui.Fail(fmt.Sprintf("Invalid version format %s", ui.Bold.Render(version))) diff --git a/cmd/service_add.go b/cmd/service_add.go index 2c26e65..42dddb5 100644 --- a/cmd/service_add.go +++ b/cmd/service_add.go @@ -50,42 +50,42 @@ var serviceAddCmd = &cobra.Command{ opts := svc.CreateOpts(version) var containerID string - // Attempt container operations if Colima is available. - // Failures are non-fatal — the service is still registered. + // Ensure Colima is installed (lazy install on first service:add). containerReady := false - if colima.IsInstalled() { - if err := colima.EnsureRunning(); err != nil { - ui.Subtle(fmt.Sprintf("Container runtime unavailable: %v", err)) - ui.Subtle("Service registered — container will start when runtime is available.") + if !colima.IsInstalled() { + if err := colimaInstallCmd.RunE(colimaInstallCmd, nil); err != nil { + return fmt.Errorf("cannot install Colima (required for services): %w", err) + } + } + + if err := colima.EnsureRunning(); err != nil { + ui.Subtle(fmt.Sprintf("Container runtime unavailable: %v", err)) + ui.Subtle("Service registered — container will start when runtime is available.") + } else { + // Pull image. + if err := ui.Step(fmt.Sprintf("Pulling %s...", opts.Image), func() (string, error) { + engine, err := container.NewEngine(config.ColimaSocketPath()) + if err != nil { + return "", fmt.Errorf("cannot connect to Docker: %w", err) + } + defer engine.Close() + _ = engine // Pull would happen via engine.PullImage() + return fmt.Sprintf("Pulled %s", opts.Image), nil + }); err != nil { + ui.Subtle(fmt.Sprintf("Image pull skipped: %v", err)) } else { - // Pull image. - if err := ui.Step(fmt.Sprintf("Pulling %s...", opts.Image), func() (string, error) { - engine, err := container.NewEngine(config.ColimaSocketPath()) - if err != nil { - return "", fmt.Errorf("cannot connect to Docker: %w", err) - } - defer engine.Close() - _ = engine // Pull would happen via engine.PullImage() - return fmt.Sprintf("Pulled %s", opts.Image), nil + // Create and start container. + if err := ui.Step(fmt.Sprintf("Starting %s %s...", svc.DisplayName(), version), func() (string, error) { + // Container creation and health check would happen here via Docker SDK. + containerID = "" // Would be set by engine.CreateAndStart() + port := svc.Port(version) + return fmt.Sprintf("%s %s running on :%d", svc.DisplayName(), version, port), nil }); err != nil { - ui.Subtle(fmt.Sprintf("Image pull skipped: %v", err)) + ui.Subtle(fmt.Sprintf("Container start skipped: %v", err)) } else { - // Create and start container. - if err := ui.Step(fmt.Sprintf("Starting %s %s...", svc.DisplayName(), version), func() (string, error) { - // Container creation and health check would happen here via Docker SDK. - containerID = "" // Would be set by engine.CreateAndStart() - port := svc.Port(version) - return fmt.Sprintf("%s %s running on :%d", svc.DisplayName(), version, port), nil - }); err != nil { - ui.Subtle(fmt.Sprintf("Container start skipped: %v", err)) - } else { - containerReady = true - } + containerReady = true } } - } else { - ui.Subtle("Colima not installed — container will start when runtime is available.") - ui.Subtle("Run 'pv install' to set up the container runtime.") } // Create data directory. diff --git a/cmd/setup.go b/cmd/setup.go index 78edb4f..79db255 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -4,11 +4,10 @@ import ( "fmt" "net/http" "os" - "strings" + "time" "github.com/charmbracelet/huh" "github.com/prvious/pv/internal/binaries" - "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/services" @@ -21,6 +20,7 @@ var setupCmd = &cobra.Command{ Use: "setup", Short: "Interactive setup wizard — choose PHP versions, tools, and services", RunE: func(cmd *cobra.Command, args []string) error { + start := time.Now() client := &http.Client{} // Load current state to pre-select installed items. @@ -54,23 +54,20 @@ var setupCmd = &cobra.Command{ selectedPHP = []string{available[len(available)-1]} } - // Tool options. + // Tool options (mago is opt-in, composer is non-negotiable but shown). type toolChoice struct { Name string Label string Checked bool } toolDefs := []toolChoice{ - {"composer", "Composer", true}, - {"mago", "Mago", isExecutable(config.BinDir() + "/mago")}, - {"colima", "Colima (container runtime for services)", colima.IsInstalled()}, + {"mago", "Mago (PHP linter & formatter)", isExecutable(config.BinDir() + "/mago")}, } var toolOptions []huh.Option[string] var selectedTools []string for _, t := range toolDefs { - label := t.Label - toolOptions = append(toolOptions, huh.NewOption(label, t.Name)) + toolOptions = append(toolOptions, huh.NewOption(t.Label, t.Name)) if t.Checked { selectedTools = append(selectedTools, t.Name) } @@ -103,8 +100,8 @@ var setupCmd = &cobra.Command{ Value(&selectedPHP), huh.NewMultiSelect[string](). - Title("Tools"). - Description("Select which tools to install"). + Title("Optional Tools"). + Description("Composer is always installed. Select additional tools:"). Options(toolOptions...). Value(&selectedTools), @@ -122,20 +119,30 @@ var setupCmd = &cobra.Command{ ) if err := form.Run(); err != nil { + cmd.SilenceUsage = true return err } fmt.Fprintln(os.Stderr) + ui.Header(version) + + // Validate TLD. + if err := config.ValidateTLD(tld); err != nil { + return err + } + + // Acquire sudo upfront. + if err := acquireSudo(); err != nil { + return err + } + // Ensure directories exist. if err := config.EnsureDirs(); err != nil { return fmt.Errorf("cannot create directories: %w", err) } // Save TLD. - if err := config.ValidateTLD(tld); err != nil { - return err - } s := &config.Settings{TLD: tld} if settings != nil { s.GlobalPHP = settings.GlobalPHP @@ -168,84 +175,60 @@ var setupCmd = &cobra.Command{ } } - // Install tools. + // Install Composer (non-negotiable). + if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Composer failed: %v\n", ui.Red.Render("!"), err) + } + + // Install optional tools (Colima is lazy-installed via service:add). toolSet := make(map[string]bool) for _, t := range selectedTools { toolSet[t] = true } - if toolSet["composer"] { - if err := ui.StepProgress("Installing Composer...", func(progress func(written, total int64)) (string, error) { - vs, _ := binaries.LoadVersions() - latest, err := binaries.FetchLatestVersion(client, binaries.Composer) - if err != nil { - return "", err - } - if err := binaries.InstallBinaryProgress(client, binaries.Composer, latest, progress); err != nil { - return "", err - } - if vs != nil { - vs.Set("composer", latest) - _ = vs.Save() - } - return "Composer installed", nil - }); err != nil { - fmt.Fprintf(os.Stderr, " %s Composer failed: %v\n", ui.Red.Render("!"), err) - } - } - if toolSet["mago"] { - if err := ui.StepProgress("Installing Mago...", func(progress func(written, total int64)) (string, error) { - vs, _ := binaries.LoadVersions() - latest, err := binaries.FetchLatestVersion(client, binaries.Mago) - if err != nil { - return "", err - } - if err := binaries.InstallBinaryProgress(client, binaries.Mago, latest, progress); err != nil { - return "", err - } - if vs != nil { - vs.Set("mago", latest) - _ = vs.Save() - } - return fmt.Sprintf("Mago %s installed", latest), nil - }); err != nil { + if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { fmt.Fprintf(os.Stderr, " %s Mago failed: %v\n", ui.Red.Render("!"), err) } } - if toolSet["colima"] { - if err := ui.StepProgress("Installing Colima...", func(progress func(written, total int64)) (string, error) { - if err := colima.Install(client, progress); err != nil { - return "", err - } - return "Colima installed", nil - }); err != nil { - fmt.Fprintf(os.Stderr, " %s Colima failed: %v\n", ui.Red.Render("!"), err) - } - } - - // Expose tools (shims + symlinks). + // Expose all installed tools (shims + symlinks). if err := tools.ExposeAll(); err != nil { fmt.Fprintf(os.Stderr, " %s Tool exposure failed: %v\n", ui.Red.Render("!"), err) } - // Print summary. - fmt.Fprintln(os.Stderr) - if len(selectedPHP) > 0 { - ui.Success(fmt.Sprintf("PHP: %s", strings.Join(selectedPHP, ", "))) + // Save version manifest. + vs, err := binaries.LoadVersions() + if err == nil { + if len(selectedPHP) > 0 { + vs.Set("php", selectedPHP[len(selectedPHP)-1]) + } + if saveErr := vs.Save(); saveErr != nil { + fmt.Fprintf(os.Stderr, " %s Cannot save version manifest: %v\n", ui.Red.Render("!"), saveErr) + } } - if len(selectedTools) > 0 { - ui.Success(fmt.Sprintf("Tools: %s", strings.Join(selectedTools, ", "))) + + // Finalize: Caddyfile, DNS, CA trust, shell PATH. + if err := bootstrapFinalize(tld); err != nil { + return err } + + // Spin up selected services. if len(selectedServices) > 0 { fmt.Fprintln(os.Stderr) - ui.Subtle("To start your selected services, run:") for _, name := range selectedServices { - fmt.Fprintf(os.Stderr, " pv service:add %s\n", name) + svc, _ := services.Lookup(name) + if svc == nil { + continue + } + svcArgs := []string{name, svc.DefaultVersion()} + if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { + fmt.Fprintf(os.Stderr, " %s Service %s failed: %v\n", ui.Red.Render("!"), name, err) + } } } - fmt.Fprintln(os.Stderr) + + ui.Footer(start, "https://pv.prvious.dev/docs") return nil }, diff --git a/internal/setup/selftest.go b/internal/setup/selftest.go deleted file mode 100644 index af00ecd..0000000 --- a/internal/setup/selftest.go +++ /dev/null @@ -1,120 +0,0 @@ -package setup - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/prvious/pv/internal/config" -) - -const checkTimeout = 10 * time.Second - -// TestResult holds the outcome of a single self-test check. -type TestResult struct { - Name string - Err error -} - -// RunSelfTest runs verification checks and returns results. -func RunSelfTest(tld string) []TestResult { - var results []TestResult - results = append(results, checkBinary("FrankenPHP", "frankenphp", "version")) - results = append(results, checkBinary("Mago", "mago", "--version")) - results = append(results, checkBinary("PHP CLI", "php", "--version")) - results = append(results, checkComposerIsolation()) - results = append(results, checkResolverConfigured(tld)) - results = append(results, checkFrankenPHPBoots()) - return results -} - -func checkBinary(displayName, binaryName string, args ...string) TestResult { - binPath := filepath.Join(config.BinDir(), binaryName) - ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) - defer cancel() - cmd := exec.CommandContext(ctx, binPath, args...) - output, err := cmd.CombinedOutput() - if ctx.Err() == context.DeadlineExceeded { - return TestResult{displayName, fmt.Errorf("timed out after %s", checkTimeout)} - } - if err != nil { - return TestResult{displayName, fmt.Errorf("%v: %s", err, strings.TrimSpace(string(output)))} - } - return TestResult{displayName, nil} -} - - -func checkComposerIsolation() TestResult { - composerShim := filepath.Join(config.BinDir(), "composer") - ctx, cancel := context.WithTimeout(context.Background(), checkTimeout) - defer cancel() - - cmd := exec.CommandContext(ctx, composerShim, "config", "--global", "home") - output, err := cmd.CombinedOutput() - if ctx.Err() == context.DeadlineExceeded { - return TestResult{"Composer isolation", fmt.Errorf("timed out after %s", checkTimeout)} - } - if err != nil { - return TestResult{"Composer isolation", fmt.Errorf("cannot run composer: %v: %s", err, strings.TrimSpace(string(output)))} - } - home := strings.TrimSpace(string(output)) - expected := config.ComposerDir() - if home != expected { - return TestResult{"Composer isolation", fmt.Errorf("COMPOSER_HOME is %q, want %q", home, expected)} - } - return TestResult{"Composer isolation", nil} -} - -func checkResolverConfigured(tld string) TestResult { - if err := CheckResolverFile(tld); err != nil { - return TestResult{"DNS resolver", err} - } - return TestResult{"DNS resolver", nil} -} - -func checkFrankenPHPBoots() TestResult { - frankenphp := filepath.Join(config.BinDir(), "frankenphp") - caddyfile := config.CaddyfilePath() - - cmd := exec.Command(frankenphp, "run", "--config", caddyfile, "--adapter", "caddyfile") - cmd.Env = append(os.Environ(), config.CaddyEnv()...) - cmd.Stdout = nil - cmd.Stderr = nil - - if err := cmd.Start(); err != nil { - return TestResult{"FrankenPHP boots", fmt.Errorf("failed to start: %w", err)} - } - - // Wait briefly to see if process stays up. - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - - select { - case err := <-done: - // Process exited on its own — likely a crash or config error. - return TestResult{"FrankenPHP boots", fmt.Errorf("exited unexpectedly: %v", err)} - case <-time.After(3 * time.Second): - // Still running after 3s — it booted successfully. - cmd.Process.Kill() - <-done - return TestResult{"FrankenPHP boots", nil} - } -} - -// PrintResults prints self-test results with checkmarks. -func PrintResults(results []TestResult) bool { - allPassed := true - for _, r := range results { - if r.Err != nil { - fmt.Printf(" x %s: %v\n", r.Name, r.Err) - allPassed = false - } else { - fmt.Printf(" ✓ %s\n", r.Name) - } - } - return allPassed -} diff --git a/internal/setup/selftest_test.go b/internal/setup/selftest_test.go deleted file mode 100644 index 4cf6391..0000000 --- a/internal/setup/selftest_test.go +++ /dev/null @@ -1,67 +0,0 @@ -package setup - -import ( - "os" - "path/filepath" - "testing" - - "github.com/prvious/pv/internal/config" -) - -func TestCheckBinary_WithFakeExecutable(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - if err := config.EnsureDirs(); err != nil { - t.Fatal(err) - } - - // Create a fake binary that exits 0. - fakeBin := filepath.Join(config.BinDir(), "fakecmd") - if err := os.WriteFile(fakeBin, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { - t.Fatal(err) - } - - result := checkBinary("FakeCmd", "fakecmd") - if result.Err != nil { - t.Errorf("checkBinary() error = %v, want nil", result.Err) - } - if result.Name != "FakeCmd" { - t.Errorf("Name = %q, want %q", result.Name, "FakeCmd") - } -} - -func TestCheckBinary_MissingBinary(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - if err := config.EnsureDirs(); err != nil { - t.Fatal(err) - } - - result := checkBinary("Missing", "nonexistent") - if result.Err == nil { - t.Error("checkBinary() error = nil, want error for missing binary") - } -} - - -func TestPrintResults_AllPass(t *testing.T) { - results := []TestResult{ - {Name: "Test A", Err: nil}, - {Name: "Test B", Err: nil}, - } - allPassed := PrintResults(results) - if !allPassed { - t.Error("PrintResults() = false, want true when all pass") - } -} - -func TestPrintResults_SomeFail(t *testing.T) { - results := []TestResult{ - {Name: "Test A", Err: nil}, - {Name: "Test B", Err: os.ErrNotExist}, - } - allPassed := PrintResults(results) - if allPassed { - t.Error("PrintResults() = true, want false when some fail") - } -} From 23ab219ec9cae6c0111bc7a03263d143de37c881 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 20:01:28 -0500 Subject: [PATCH 11/16] Fix e2e scripts for new install flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: --php 8.4 → --with="php:8.4" - update.sh: remove mago check (no longer installed by default) --- scripts/e2e/install.sh | 2 +- scripts/e2e/update.sh | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/e2e/install.sh b/scripts/e2e/install.sh index 8058864..a4e2723 100755 --- a/scripts/e2e/install.sh +++ b/scripts/e2e/install.sh @@ -2,7 +2,7 @@ set -euo pipefail source "$(dirname "$0")/helpers.sh" -pv install --php 8.4 +pv install --with="php:8.4" echo "--- install output above ---" pv php:install 8.3 diff --git a/scripts/e2e/update.sh b/scripts/e2e/update.sh index 5b6a7f5..3507f25 100755 --- a/scripts/e2e/update.sh +++ b/scripts/e2e/update.sh @@ -3,6 +3,5 @@ set -euo pipefail source "$(dirname "$0")/helpers.sh" pv update -ls ~/.pv/bin/mago || { echo "FAIL: mago missing after update"; exit 1; } ls ~/.pv/bin/composer || { echo "FAIL: composer missing after update"; exit 1; } echo "OK: pv update completed" From a8d6ff3d128e8952deffbce3db014df5adba412d Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 20:06:20 -0500 Subject: [PATCH 12/16] Add debug output to composer e2e test to diagnose CI failure --- scripts/e2e/composer.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/composer.sh b/scripts/e2e/composer.sh index 49a9e0c..35638d5 100755 --- a/scripts/e2e/composer.sh +++ b/scripts/e2e/composer.sh @@ -16,13 +16,20 @@ test -f ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not foun # ── 2. Verify COMPOSER_HOME isolation ────────────────────────────────── echo "==> Verify COMPOSER_HOME points to ~/.pv/composer" -COMPOSER_HOME_OUTPUT=$(composer config --global home 2>/dev/null) +echo " Debug: composer shim at $(which composer)" +echo " Debug: shim contents:" +head -5 ~/.pv/bin/composer || true +echo " Debug: PHP binary:" +ls -la ~/.pv/php/8.4/php || true +echo " Debug: composer.phar:" +ls -la ~/.pv/internal/bin/composer.phar || true +COMPOSER_HOME_OUTPUT=$(composer config --global home 2>&1) echo " COMPOSER_HOME = $COMPOSER_HOME_OUTPUT" assert_contains "$COMPOSER_HOME_OUTPUT" ".pv/composer" "COMPOSER_HOME not isolated under ~/.pv/composer" # ── 3. Verify COMPOSER_CACHE_DIR isolation ───────────────────────────── echo "==> Verify COMPOSER_CACHE_DIR points to ~/.pv/composer/cache" -COMPOSER_CACHE_OUTPUT=$(composer config --global cache-dir 2>/dev/null) +COMPOSER_CACHE_OUTPUT=$(composer config --global cache-dir 2>&1) echo " COMPOSER_CACHE_DIR = $COMPOSER_CACHE_OUTPUT" assert_contains "$COMPOSER_CACHE_OUTPUT" ".pv/composer/cache" "COMPOSER_CACHE_DIR not isolated under ~/.pv/composer/cache" From 58e6fe9a47a7589a306233a7d5176be0482540c8 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 22:13:08 -0500 Subject: [PATCH 13/16] Switch composer from shim to symlink, isolate via pv env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer.phar is self-executable — no need for a bash shim that resolves PHP and sets env vars. Replace with a simple symlink to composer.phar and export COMPOSER_HOME/COMPOSER_CACHE_DIR via `pv env` instead. - Change composer ExposureType from ExposureShim to ExposureSymlink - Remove composerShimScript and writeComposerShim from shims.go - Add COMPOSER_HOME and COMPOSER_CACHE_DIR exports to `pv env` - Update bootstrap.go shell config to use `eval "$(pv env)"` - Make composer.phar executable (chmod 0755) for symlink approach - Delete composer_e2e_test.go (tested shim behavior) - Update shim_test.go, tool_test.go, env_test.go, e2e/composer.sh --- cmd/bootstrap.go | 17 +- cmd/env.go | 6 + cmd/env_test.go | 16 ++ internal/binaries/install.go | 2 +- internal/phpenv/composer_e2e_test.go | 379 --------------------------- internal/phpenv/shim_test.go | 59 +---- internal/tools/shims.go | 77 ------ internal/tools/tool.go | 3 +- internal/tools/tool_test.go | 6 +- scripts/e2e/composer.sh | 56 ++-- 10 files changed, 69 insertions(+), 552 deletions(-) delete mode 100644 internal/phpenv/composer_e2e_test.go diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index ec05f05..eaef50a 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -70,15 +70,22 @@ func bootstrapFinalize(tld string) error { return err } - // Shell PATH. + // Shell configuration (PATH + env vars via `pv env`). if err := ui.Step("Configuring shell...", func() (string, error) { shell := setup.DetectShell() configFile := setup.ShellConfigFile(shell) - line := setup.PathExportLine(shell) + + var evalLine string + switch shell { + case "fish": + evalLine = "pv env | source" + default: + evalLine = `eval "$(pv env)"` + } data, err := os.ReadFile(configFile) - if err == nil && strings.Contains(string(data), line) { - return "PATH already configured", nil + if err == nil && strings.Contains(string(data), "pv env") { + return "Shell already configured", nil } f, err := os.OpenFile(configFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) @@ -86,7 +93,7 @@ func bootstrapFinalize(tld string) error { return "", fmt.Errorf("cannot open %s: %w", configFile, err) } defer f.Close() - if _, err := fmt.Fprintf(f, "\n# pv\n%s\n", line); err != nil { + if _, err := fmt.Fprintf(f, "\n# pv\n%s\n", evalLine); err != nil { return "", fmt.Errorf("cannot write to %s: %w", configFile, err) } diff --git a/cmd/env.go b/cmd/env.go index 306b9f8..7b51ff4 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -28,12 +28,18 @@ Or run it directly to configure your current session.`, localBinDir := filepath.Join(home, ".local", "bin") binDir := filepath.Join(home, ".pv", "bin") composerBinDir := filepath.Join(home, ".pv", "composer", "vendor", "bin") + composerHome := filepath.Join(home, ".pv", "composer") + composerCacheDir := filepath.Join(home, ".pv", "composer", "cache") switch shell { case "fish": fmt.Fprintf(cmd.OutOrStdout(), "fish_add_path -g %q %q %q;\n", localBinDir, binDir, composerBinDir) + fmt.Fprintf(cmd.OutOrStdout(), "set -gx COMPOSER_HOME %q;\n", composerHome) + fmt.Fprintf(cmd.OutOrStdout(), "set -gx COMPOSER_CACHE_DIR %q;\n", composerCacheDir) default: fmt.Fprintf(cmd.OutOrStdout(), "export PATH=%q:%q:%q:\"$PATH\";\n", localBinDir, binDir, composerBinDir) + fmt.Fprintf(cmd.OutOrStdout(), "export COMPOSER_HOME=%q;\n", composerHome) + fmt.Fprintf(cmd.OutOrStdout(), "export COMPOSER_CACHE_DIR=%q;\n", composerCacheDir) } return nil diff --git a/cmd/env_test.go b/cmd/env_test.go index 85fe396..cd3c78b 100644 --- a/cmd/env_test.go +++ b/cmd/env_test.go @@ -51,6 +51,16 @@ func TestEnv_Zsh(t *testing.T) { if !strings.Contains(out, "$PATH") { t.Errorf("expected $PATH in output, got:\n%s", out) } + composerHome := filepath.Join(home, ".pv", "composer") + if !strings.Contains(out, "export COMPOSER_HOME=") { + t.Errorf("expected COMPOSER_HOME export, got:\n%s", out) + } + if !strings.Contains(out, composerHome) { + t.Errorf("expected %q in output, got:\n%s", composerHome, out) + } + if !strings.Contains(out, "export COMPOSER_CACHE_DIR=") { + t.Errorf("expected COMPOSER_CACHE_DIR export, got:\n%s", out) + } } func TestEnv_Bash(t *testing.T) { @@ -97,6 +107,12 @@ func TestEnv_Fish(t *testing.T) { if !strings.Contains(out, binDir) { t.Errorf("expected %q in output, got:\n%s", binDir, out) } + if !strings.Contains(out, "set -gx COMPOSER_HOME") { + t.Errorf("expected COMPOSER_HOME in fish output, got:\n%s", out) + } + if !strings.Contains(out, "set -gx COMPOSER_CACHE_DIR") { + t.Errorf("expected COMPOSER_CACHE_DIR in fish output, got:\n%s", out) + } } func TestEnv_NoShellEnv(t *testing.T) { diff --git a/internal/binaries/install.go b/internal/binaries/install.go index 3fe433f..07a13e8 100644 --- a/internal/binaries/install.go +++ b/internal/binaries/install.go @@ -139,5 +139,5 @@ func installComposer(client *http.Client, url string, b Binary, version string, } } - return nil + return os.Chmod(destPath, 0755) } diff --git a/internal/phpenv/composer_e2e_test.go b/internal/phpenv/composer_e2e_test.go deleted file mode 100644 index 9ad3468..0000000 --- a/internal/phpenv/composer_e2e_test.go +++ /dev/null @@ -1,379 +0,0 @@ -package phpenv - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "github.com/prvious/pv/internal/config" -) - -// fakePHP is a bash script that acts as a stand-in for the real PHP binary. -// When invoked, it writes the environment and arguments to a capture file -// so tests can inspect exactly what the composer shim passed through. -const fakePHP = `#!/bin/bash -CAPTURE_FILE="${FAKE_PHP_CAPTURE:-/dev/null}" -{ - echo "COMPOSER_HOME=$COMPOSER_HOME" - echo "COMPOSER_CACHE_DIR=$COMPOSER_CACHE_DIR" - echo "ARGS=$*" - echo "PHP_VERSION=8.4" -} > "$CAPTURE_FILE" -` - -// fakeComposerPhar is a minimal PHP script that prints Composer-like output. -// When the fake PHP receives it as the first argument, the test can verify -// the phar path was passed correctly. -const fakeComposerPhar = ` execIdx { - t.Error("COMPOSER_HOME is set after exec — it won't take effect") + if target != config.ComposerPharPath() { + t.Errorf("composer symlink target = %q, want %q", target, config.ComposerPharPath()) } } diff --git a/internal/tools/shims.go b/internal/tools/shims.go index e01102a..a803a16 100644 --- a/internal/tools/shims.go +++ b/internal/tools/shims.go @@ -70,66 +70,6 @@ fi exec "$BINARY" "$@" ` -const composerShimScript = `#!/bin/bash -# pv Composer shim — isolates Composer home and cache under ~/.pv/composer. -set -euo pipefail - -export COMPOSER_HOME="%s" -export COMPOSER_CACHE_DIR="%s" - -PV_PHP_DIR="%s" -PV_SETTINGS="%s" -COMPOSER_PHAR="%s" - -# Read global default version from settings. -global_php() { - if [ -f "$PV_SETTINGS" ]; then - grep -o '"global_php"[[:space:]]*:[[:space:]]*"[^"]*"' "$PV_SETTINGS" | \ - grep -o '"[^"]*"$' | tr -d '"' || true - fi -} - -# Walk up directories looking for .pv-php or composer.json. -resolve_version() { - local dir="$PWD" - while [ "$dir" != "/" ]; do - if [ -f "$dir/.pv-php" ]; then - cat "$dir/.pv-php" | tr -d '[:space:]' - return - fi - if [ -f "$dir/composer.json" ]; then - local constraint - constraint=$(grep -o '"php"[[:space:]]*:[[:space:]]*"[^"]*"' "$dir/composer.json" | \ - grep -o '"[^"]*"$' | tr -d '"' || true) - if [ -n "$constraint" ]; then - local ver - ver=$(echo "$constraint" | grep -o '[0-9]\+\.[0-9]\+' | head -1) - if [ -n "$ver" ] && [ -d "$PV_PHP_DIR/$ver" ]; then - echo "$ver" - return - fi - fi - fi - dir=$(dirname "$dir") - done - global_php -} - -VERSION=$(resolve_version) -if [ -z "$VERSION" ]; then - echo "pv: no PHP version configured. Run: pv php:install " >&2 - exit 1 -fi - -PHP_BINARY="$PV_PHP_DIR/$VERSION/php" -if [ ! -x "$PHP_BINARY" ]; then - echo "pv: PHP $VERSION is not installed. Run: pv php:install $VERSION" >&2 - exit 1 -fi - -exec "$PHP_BINARY" "$COMPOSER_PHAR" "$@" -` - // writePhpShim writes the PHP version-resolving shim to ~/.pv/bin/php. func writePhpShim() error { phpDir := config.PhpDir() @@ -144,20 +84,3 @@ func writePhpShim() error { return nil } -// writeComposerShim writes the Composer shim to ~/.pv/bin/composer. -func writeComposerShim() error { - binDir := config.BinDir() - - shimPath := filepath.Join(binDir, "composer") - content := fmt.Sprintf(composerShimScript, - config.ComposerDir(), - config.ComposerCacheDir(), - config.PhpDir(), - config.SettingsPath(), - config.ComposerPharPath(), - ) - if err := os.WriteFile(shimPath, []byte(content), 0755); err != nil { - return fmt.Errorf("cannot write composer shim: %w", err) - } - return nil -} diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 13ccfa8..940592f 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -62,11 +62,10 @@ var registry = map[string]*Tool{ "composer": { DisplayName: "Composer", AutoExpose: true, - Exposure: ExposureShim, + Exposure: ExposureSymlink, InternalPath: func() string { return config.ComposerPharPath() }, - WriteShim: writeComposerShim, }, "mago": { DisplayName: "Mago", diff --git a/internal/tools/tool_test.go b/internal/tools/tool_test.go index 574535e..df77bf9 100644 --- a/internal/tools/tool_test.go +++ b/internal/tools/tool_test.go @@ -157,9 +157,9 @@ func TestExposeAll(t *testing.T) { t.Error("php shim not created by ExposeAll") } - // Composer shim should exist. - if _, err := os.Stat(filepath.Join(config.BinDir(), "composer")); err != nil { - t.Error("composer shim not created by ExposeAll") + // Composer symlink should exist. + if _, err := os.Lstat(filepath.Join(config.BinDir(), "composer")); err != nil { + t.Error("composer symlink not created by ExposeAll") } // Mago symlink should exist. diff --git a/scripts/e2e/composer.sh b/scripts/e2e/composer.sh index 35638d5..ed4b02f 100755 --- a/scripts/e2e/composer.sh +++ b/scripts/e2e/composer.sh @@ -5,32 +5,34 @@ source "$(dirname "$0")/helpers.sh" # Set up PATH via pv env so we can use bare `composer` and `php` commands eval "$(pv env)" -# ── 1. Verify composer shim exists and is executable ─────────────────── -echo "==> Verify composer shim exists" +# ── 1. Verify composer symlink exists and points to composer.phar ────── +echo "==> Verify composer symlink exists" ls -la ~/.pv/bin/composer -test -x ~/.pv/bin/composer || { echo "FAIL: composer shim not executable"; exit 1; } +test -L ~/.pv/bin/composer || { echo "FAIL: composer is not a symlink"; exit 1; } -echo "==> Verify composer.phar exists" +echo "==> Verify composer.phar exists and is executable" ls -la ~/.pv/internal/bin/composer.phar test -f ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } +test -x ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not executable"; exit 1; } -# ── 2. Verify COMPOSER_HOME isolation ────────────────────────────────── -echo "==> Verify COMPOSER_HOME points to ~/.pv/composer" -echo " Debug: composer shim at $(which composer)" -echo " Debug: shim contents:" -head -5 ~/.pv/bin/composer || true -echo " Debug: PHP binary:" -ls -la ~/.pv/php/8.4/php || true -echo " Debug: composer.phar:" -ls -la ~/.pv/internal/bin/composer.phar || true +# ── 2. Verify COMPOSER_HOME isolation via pv env ────────────────────── +echo "==> Verify COMPOSER_HOME is set by pv env" +echo " COMPOSER_HOME = $COMPOSER_HOME" +assert_contains "$COMPOSER_HOME" ".pv/composer" "COMPOSER_HOME not isolated under ~/.pv/composer" + +echo "==> Verify composer config --global home" COMPOSER_HOME_OUTPUT=$(composer config --global home 2>&1) -echo " COMPOSER_HOME = $COMPOSER_HOME_OUTPUT" +echo " composer config home = $COMPOSER_HOME_OUTPUT" assert_contains "$COMPOSER_HOME_OUTPUT" ".pv/composer" "COMPOSER_HOME not isolated under ~/.pv/composer" -# ── 3. Verify COMPOSER_CACHE_DIR isolation ───────────────────────────── -echo "==> Verify COMPOSER_CACHE_DIR points to ~/.pv/composer/cache" +# ── 3. Verify COMPOSER_CACHE_DIR isolation via pv env ───────────────── +echo "==> Verify COMPOSER_CACHE_DIR is set by pv env" +echo " COMPOSER_CACHE_DIR = $COMPOSER_CACHE_DIR" +assert_contains "$COMPOSER_CACHE_DIR" ".pv/composer/cache" "COMPOSER_CACHE_DIR not isolated under ~/.pv/composer/cache" + +echo "==> Verify composer config --global cache-dir" COMPOSER_CACHE_OUTPUT=$(composer config --global cache-dir 2>&1) -echo " COMPOSER_CACHE_DIR = $COMPOSER_CACHE_OUTPUT" +echo " composer config cache-dir = $COMPOSER_CACHE_OUTPUT" assert_contains "$COMPOSER_CACHE_OUTPUT" ".pv/composer/cache" "COMPOSER_CACHE_DIR not isolated under ~/.pv/composer/cache" # ── 4. Verify nothing touches ~/.composer ────────────────────────────── @@ -85,21 +87,11 @@ if [ -d ~/.pv/composer/vendor/laravel/installer ]; then fi echo " OK: laravel/installer removed" -# ── 10. Composer version resolves per-project PHP ───────────────────── -echo "==> Verify composer uses correct PHP per project" - -# In 8.3 project dir, composer should use PHP 8.3. -if [ -d /tmp/e2e-php83 ]; then - PHP_VER=$(cd /tmp/e2e-php83 && composer --version 2>&1 | head -1) - echo " e2e-php83 dir: $PHP_VER" - # Composer itself runs — that's proof the PHP resolution worked. - assert_contains "$PHP_VER" "Composer" "composer did not run in e2e-php83 project" -fi - -# In global context, should use PHP 8.4. -PHP_VER=$(cd /tmp && composer --version 2>&1 | head -1) -echo " /tmp (global): $PHP_VER" -assert_contains "$PHP_VER" "Composer" "composer did not run in global context" +# ── 10. Verify composer runs correctly ───────────────────────────────── +echo "==> Verify composer --version works" +PHP_VER=$(composer --version 2>&1 | head -1) +echo " $PHP_VER" +assert_contains "$PHP_VER" "Composer" "composer did not produce expected version output" echo "" echo "OK: Composer containment verified" From a0625487f5f43df250ba38621c8776fc458e11b1 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 22:15:27 -0500 Subject: [PATCH 14/16] Fix e2e verify-install: eval pv env before running composer Composer.phar needs php on PATH. The verify script wasn't sourcing pv env, so the php shim wasn't available when running composer. --- scripts/e2e/verify-install.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/e2e/verify-install.sh b/scripts/e2e/verify-install.sh index 3c99402..c58a902 100755 --- a/scripts/e2e/verify-install.sh +++ b/scripts/e2e/verify-install.sh @@ -2,6 +2,9 @@ set -euo pipefail source "$(dirname "$0")/helpers.sh" +# Set up PATH so php shim and composer symlink are available. +eval "$(pv env)" + echo "==> Verify 8.4 binaries" ls -la ~/.pv/php/8.4/frankenphp ls -la ~/.pv/php/8.4/php @@ -29,8 +32,8 @@ grep -q '"global_php": "8.4"' ~/.pv/config/settings.json || { echo "FAIL: settin echo "==> Verify resolver" cat /etc/resolver/test -echo "==> Verify composer shim and phar" -test -x ~/.pv/bin/composer || { echo "FAIL: composer shim not executable"; exit 1; } +echo "==> Verify composer symlink and phar" +test -L ~/.pv/bin/composer || { echo "FAIL: composer is not a symlink"; exit 1; } test -f ~/.pv/internal/bin/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } echo "==> Verify composer directories" From 41b1a05f9aeed92bf0e581795d24e25e4da1e894 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 22:18:21 -0500 Subject: [PATCH 15/16] Update doctor for composer symlink: check env vars instead of running composer Doctor's composer isolation check was executing composer.phar which needs php on PATH. Switch to checking COMPOSER_HOME/COMPOSER_CACHE_DIR env vars directly since isolation is now handled by `pv env`. Also rename "Composer shim" to "Composer symlink" and eval pv env in e2e doctor script. --- cmd/doctor.go | 84 ++++++++++++++++++++++++------------------- scripts/e2e/doctor.sh | 6 +++- 2 files changed, 52 insertions(+), 38 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 30d058b..2496df4 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -218,15 +218,17 @@ func runEnvironmentChecks() sectionResult { }) } - // Composer shim exists. - composerShim := filepath.Join(binDir, "composer") - if isExecutable(composerShim) { - checks = append(checks, check{Name: "Composer shim", Status: true}) + // Composer symlink exists. + composerLink := filepath.Join(binDir, "composer") + if _, err := os.Readlink(composerLink); err == nil { + checks = append(checks, check{Name: "Composer symlink", Status: true}) + } else if isExecutable(composerLink) { + checks = append(checks, check{Name: "Composer binary", Status: true}) } else { checks = append(checks, check{ - Name: "Composer shim", + Name: "Composer symlink", Status: false, - Message: "~/.pv/bin/composer not found or not executable", + Message: "~/.pv/bin/composer not found", Fix: "pv install", }) } @@ -276,38 +278,46 @@ func runComposerIsolationChecks() sectionResult { }) } - // Run composer shim to verify COMPOSER_HOME. - composerShim := filepath.Join(config.BinDir(), "composer") - if isExecutable(composerShim) { - out, err := exec.Command(composerShim, "config", "--global", "home").CombinedOutput() - if err == nil { - home := strings.TrimSpace(string(out)) - expected := config.ComposerDir() - if home == expected { - checks = append(checks, check{Name: "COMPOSER_HOME isolated", Status: true}) - } else { - checks = append(checks, check{ - Name: "COMPOSER_HOME isolated", - Status: false, - Message: fmt.Sprintf("COMPOSER_HOME is %q, expected %q", home, expected), - }) - } - } + // Verify COMPOSER_HOME and COMPOSER_CACHE_DIR env vars are set correctly. + // Isolation is handled by `pv env` which exports these variables. + composerHomeEnv := os.Getenv("COMPOSER_HOME") + expectedHome := config.ComposerDir() + if composerHomeEnv == expectedHome { + checks = append(checks, check{Name: "COMPOSER_HOME isolated", Status: true}) + } else if composerHomeEnv != "" { + checks = append(checks, check{ + Name: "COMPOSER_HOME isolated", + Status: false, + Message: fmt.Sprintf("COMPOSER_HOME is %q, expected %q", composerHomeEnv, expectedHome), + Fix: `Add to your shell config: eval "$(pv env)"`, + }) + } else { + checks = append(checks, check{ + Name: "COMPOSER_HOME isolated", + Status: false, + Message: "COMPOSER_HOME not set", + Fix: `Add to your shell config: eval "$(pv env)"`, + }) + } - out, err = exec.Command(composerShim, "config", "--global", "cache-dir").CombinedOutput() - if err == nil { - cacheDir := strings.TrimSpace(string(out)) - expected := config.ComposerCacheDir() - if cacheDir == expected { - checks = append(checks, check{Name: "Composer cache isolated", Status: true}) - } else { - checks = append(checks, check{ - Name: "Composer cache isolated", - Status: false, - Message: fmt.Sprintf("cache-dir is %q, expected %q", cacheDir, expected), - }) - } - } + composerCacheEnv := os.Getenv("COMPOSER_CACHE_DIR") + expectedCache := config.ComposerCacheDir() + if composerCacheEnv == expectedCache { + checks = append(checks, check{Name: "Composer cache isolated", Status: true}) + } else if composerCacheEnv != "" { + checks = append(checks, check{ + Name: "Composer cache isolated", + Status: false, + Message: fmt.Sprintf("COMPOSER_CACHE_DIR is %q, expected %q", composerCacheEnv, expectedCache), + Fix: `Add to your shell config: eval "$(pv env)"`, + }) + } else { + checks = append(checks, check{ + Name: "Composer cache isolated", + Status: false, + Message: "COMPOSER_CACHE_DIR not set", + Fix: `Add to your shell config: eval "$(pv env)"`, + }) } // Warn if ~/.composer/ also exists (potential confusion). diff --git a/scripts/e2e/doctor.sh b/scripts/e2e/doctor.sh index e7abe33..ce139d9 100755 --- a/scripts/e2e/doctor.sh +++ b/scripts/e2e/doctor.sh @@ -2,7 +2,10 @@ set -euo pipefail source "$(dirname "$0")/helpers.sh" -# Run doctor with sudo -E so it sees the same HOME and PID file as the server +# Set up PATH and env vars so doctor can verify them. +eval "$(pv env)" + +# Run doctor with sudo -E so it sees the same HOME, PID file, and env vars # (the server was started with sudo -E pv start &). echo "==> Run pv doctor (server running)" OUTPUT=$(sudo -E pv doctor 2>&1 || true) @@ -15,6 +18,7 @@ assert_contains "$OUTPUT" "Composer" "Composer not detected" # Environment checks. assert_contains "$OUTPUT" "PATH" "PATH check missing" assert_contains "$OUTPUT" "PHP shim" "PHP shim check missing" +assert_contains "$OUTPUT" "Composer symlink" "Composer symlink check missing" # Composer isolation checks. assert_contains "$OUTPUT" "Composer home directory" "Composer home directory check missing" From ffa4bdb6a5276a1702418d181c3db1765efd98f6 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 22:34:09 -0500 Subject: [PATCH 16/16] Fix global PHP not set on reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs: 1. php:install returned early when already installed without checking if global_php was set — now sets it if missing. 2. pv install --force overwrote settings.json with empty GlobalPHP — now loads existing settings and preserves GlobalPHP. --- cmd/install.go | 6 +++++- cmd/php_install.go | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/install.go b/cmd/install.go index b918351..34c818e 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -129,7 +129,11 @@ Examples: if err := config.EnsureDirs(); err != nil { return "", fmt.Errorf("cannot create directories: %w", err) } - settings := &config.Settings{TLD: installTLD} + settings, _ := config.LoadSettings() + if settings == nil { + settings = &config.Settings{} + } + settings.TLD = installTLD if err := settings.Save(); err != nil { return "", fmt.Errorf("cannot save settings: %w", err) } diff --git a/cmd/php_install.go b/cmd/php_install.go index 9646133..cd85f75 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -47,6 +47,12 @@ var phpInstallCmd = &cobra.Command{ } if phpenv.IsInstalled(version) { + // Ensure global default is set even if already installed. + if _, err := phpenv.GlobalVersion(); err != nil { + if err := phpenv.SetGlobal(version); err != nil { + return err + } + } fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("PHP %s is already installed", version)) fmt.Fprintln(os.Stderr)