diff --git a/CLAUDE.md b/CLAUDE.md index 2c178d3..3535dec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,90 +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 - 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 - 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/ # symlinks to active global version + Mago, Composer -├── 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 + +All user-facing operations MUST use `internal/ui/` helpers. Never use raw `fmt.Print` for status output. + +- **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`). + +## Import cycle: phpenv ↔ tools + +`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. -## Multi-version architecture +## Testing conventions -- **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}`). +- **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`. -## Testing strategy +## Multi-version PHP -- **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. +- 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..89a4dae 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 → ~/.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 +│ └── 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/bootstrap.go b/cmd/bootstrap.go new file mode 100644 index 0000000..eaef50a --- /dev/null +++ b/cmd/bootstrap.go @@ -0,0 +1,106 @@ +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 configuration (PATH + env vars via `pv env`). + if err := ui.Step("Configuring shell...", func() (string, error) { + shell := setup.DetectShell() + configFile := setup.ShellConfigFile(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), "pv env") { + return "Shell 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", evalLine); 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/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..4a693c4 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.MustGet("colima") + if 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_path.go b/cmd/colima_path.go new file mode 100644 index 0000000..2972b8d --- /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.MustGet("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..4d23fc1 --- /dev/null +++ b/cmd/colima_uninstall.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + "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() { + 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) { + return "", err + } + + if err := tools.Unexpose(tools.MustGet("colima")); err != nil { + return "", fmt.Errorf("cannot unexpose colima: %w", err) + } + + return "Colima removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(colimaUninstallCmd) +} diff --git a/cmd/colima_update.go b/cmd/colima_update.go new file mode 100644 index 0000000..f23bbdc --- /dev/null +++ b/cmd/colima_update.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/colima" + "github.com/prvious/pv/internal/tools" + "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 + } + + // 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) + } + } + + return 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..598932a 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.MustGet("composer") + if 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..64b7441 --- /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.MustGet("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_uninstall.go b/cmd/composer_uninstall.go new file mode 100644 index 0000000..d0e5004 --- /dev/null +++ b/cmd/composer_uninstall.go @@ -0,0 +1,37 @@ +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 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) { + 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) { + 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/composer_update.go b/cmd/composer_update.go new file mode 100644 index 0000000..f500316 --- /dev/null +++ b/cmd/composer_update.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/prvious/pv/internal/tools" + "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 { + // Delegate download to :download (Composer always re-downloads). + if err := composerDownloadCmd.RunE(composerDownloadCmd, nil); err != nil { + return 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 nil + }, +} + +func init() { + rootCmd.AddCommand(composerUpdateCmd) +} diff --git a/cmd/daemon.go b/cmd/daemon.go index 794e877..b57f0a2 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) @@ -71,8 +66,27 @@ var daemonUninstallCmd = &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() { - daemonCmd.AddCommand(daemonInstallCmd) - daemonCmd.AddCommand(daemonUninstallCmd) - rootCmd.AddCommand(daemonCmd) + rootCmd.AddCommand(daemonEnableCmd) + rootCmd.AddCommand(daemonDisableCmd) + rootCmd.AddCommand(daemonRestartCmd) } 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/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/cmd/install.go b/cmd/install.go index 876c46a..34c818e 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -2,67 +2,117 @@ package cmd import ( "fmt" - "net/http" "os" - "os/exec" "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/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) { @@ -71,196 +121,64 @@ 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} + 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) } - - // 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 - }); err != nil { - return err - } - - // 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)) - } - - // Write shims. - if err := phpenv.WriteShims(); err != nil { - return "", fmt.Errorf("cannot write shims: %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 + return "Directories created", nil }); err != nil { - return err + return ui.ErrAlreadyPrinted } - // 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 5: 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 6: 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 7: 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 8: 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 @@ -277,9 +195,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/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..bb88dd9 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.MustGet("mago") + if 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..0eca6ca --- /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.MustGet("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_uninstall.go b/cmd/mago_uninstall.go new file mode 100644 index 0000000..71942c3 --- /dev/null +++ b/cmd/mago_uninstall.go @@ -0,0 +1,33 @@ +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 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) { + 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) { + return "", err + } + + return "Mago removed", nil + }) + }, +} + +func init() { + rootCmd.AddCommand(magoUninstallCmd) +} diff --git a/cmd/mago_update.go b/cmd/mago_update.go new file mode 100644 index 0000000..6e03b9a --- /dev/null +++ b/cmd/mago_update.go @@ -0,0 +1,53 @@ +package cmd + +import ( + "fmt" + "net/http" + + "github.com/prvious/pv/internal/binaries" + "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{} + + 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 + } + + // Delegate download to :download. + if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + return err + } + + // 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 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..cd85f75 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -7,6 +7,7 @@ import ( "regexp" "github.com/prvious/pv/internal/phpenv" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -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))) @@ -29,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) @@ -37,13 +61,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 +74,16 @@ 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.MustGet(name) + if t.AutoExpose { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot expose %s: %w", name, err) + } + } + } + fmt.Fprintln(os.Stderr) return nil }, diff --git a/cmd/php_path.go b/cmd/php_path.go new file mode 100644 index 0000000..dc7a810 --- /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.MustGet("php") + fp := tools.MustGet("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_uninstall.go b/cmd/php_uninstall.go new file mode 100644 index 0000000..ca1dd98 --- /dev/null +++ b/cmd/php_uninstall.go @@ -0,0 +1,35 @@ +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"} { + if err := tools.Unexpose(tools.MustGet(name)); err != nil { + return "", fmt.Errorf("cannot unexpose %s: %w", name, err) + } + } + + 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/php_update.go b/cmd/php_update.go new file mode 100644 index 0000000..e88b74c --- /dev/null +++ b/cmd/php_update.go @@ -0,0 +1,56 @@ +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.MustGet(name) + if tools.IsExposed(t) { + if err := tools.Expose(t); err != nil { + return fmt.Errorf("cannot re-expose %s: %w", name, err) + } + } + } + + return nil + }, +} + +func init() { + rootCmd.AddCommand(phpUpdateCmd) +} 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. 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 a7127ef..79db255 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -4,14 +4,14 @@ 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" + "github.com/prvious/pv/internal/tools" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -20,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. @@ -53,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) } @@ -102,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), @@ -121,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 @@ -167,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 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) } - // Write shims. - if err := phpenv.WriteShims(); err != nil { - fmt.Fprintf(os.Stderr, " %s Shims failed: %v\n", ui.Red.Render("!"), err) + // 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) + } } - // Print summary. - fmt.Fprintln(os.Stderr) - if len(selectedPHP) > 0 { - ui.Success(fmt.Sprintf("PHP: %s", strings.Join(selectedPHP, ", "))) - } - 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/cmd/uninstall.go b/cmd/uninstall.go index 5204280..399450b 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,143 @@ 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). + if err := colimaUninstallCmd.RunE(colimaUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s Colima uninstall failed: %v\n", ui.Red.Render("!"), err) } - - if colima.IsInstalled() && colima.IsRunning() { - fmt.Println("Stopping Colima VM...") - _ = colima.Stop() - _ = colima.Delete() - fmt.Println(" Done") + if err := phpUninstallCmd.RunE(phpUninstallCmd, nil); err != nil { + fmt.Fprintf(os.Stderr, " %s PHP uninstall failed: %v\n", ui.Red.Render("!"), err) } - - // 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 - } - } + 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) } - // 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. + // 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) + } 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 +219,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/cmd/update.go b/cmd/update.go index ae5239f..327d6a6 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -2,112 +2,115 @@ package cmd import ( "fmt" + "net/http" + "os" "strings" + "syscall" "time" - "net/http" - - "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 // reached only if syscall.Exec failed (error already printed) + } } - type updateInfo struct { - binary binaries.Binary - latest string - current string - needed bool + // 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") } - 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) + failures = append(failures, "Mago") } - 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) + failures = append(failures, "Composer") } - // 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) + failures = append(failures, "Colima") } - return strings.Join(results, ", "), nil - }); err != nil { - return err } ui.Footer(start, "") + if len(failures) > 0 { + return fmt.Errorf("some updates failed: %s", strings.Join(failures, ", ")) + } + return nil }, } +// 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..07a13e8 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 { @@ -138,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/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/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 = ` %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/phpenv/shim_test.go b/internal/phpenv/shim_test.go index fd5963b..e2b626b 100644 --- a/internal/phpenv/shim_test.go +++ b/internal/phpenv/shim_test.go @@ -38,7 +38,7 @@ func TestWriteShims_CreatesPhpShim(t *testing.T) { } } -func TestWriteShims_CreatesComposerShim(t *testing.T) { +func TestWriteShims_CreatesComposerSymlink(t *testing.T) { home := t.TempDir() t.Setenv("HOME", home) if err := config.EnsureDirs(); err != nil { @@ -49,59 +49,12 @@ func TestWriteShims_CreatesComposerShim(t *testing.T) { t.Fatalf("WriteShims() error = %v", err) } - shimPath := filepath.Join(config.BinDir(), "composer") - info, err := os.Stat(shimPath) + linkPath := filepath.Join(config.BinDir(), "composer") + target, err := os.Readlink(linkPath) if err != nil { - t.Fatalf("composer shim not created: %v", err) - } - if info.Mode()&0111 == 0 { - t.Error("composer shim is not executable") - } - - content, _ := os.ReadFile(shimPath) - s := string(content) - - if !strings.Contains(s, "#!/bin/bash") { - t.Error("composer shim missing shebang") - } - if !strings.Contains(s, "COMPOSER_HOME=") { - t.Error("composer shim missing COMPOSER_HOME") - } - if !strings.Contains(s, "COMPOSER_CACHE_DIR=") { - t.Error("composer shim missing COMPOSER_CACHE_DIR") - } - if !strings.Contains(s, config.ComposerDir()) { - t.Error("composer shim not pointing to ~/.pv/composer") - } - if !strings.Contains(s, config.ComposerCacheDir()) { - t.Error("composer shim not pointing to ~/.pv/composer/cache") - } - if !strings.Contains(s, config.ComposerPharPath()) { - t.Error("composer shim not pointing to composer.phar path") - } -} - -func TestWriteShims_ComposerShimSetsIsolation(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - if err := config.EnsureDirs(); err != nil { - t.Fatal(err) - } - - if err := WriteShims(); err != nil { - t.Fatalf("WriteShims() error = %v", err) - } - - content, _ := os.ReadFile(filepath.Join(config.BinDir(), "composer")) - s := string(content) - - // Verify COMPOSER_HOME is set before any exec call. - homeIdx := strings.Index(s, "export COMPOSER_HOME=") - execIdx := strings.Index(s, "exec ") - if homeIdx == -1 || execIdx == -1 { - t.Fatal("missing COMPOSER_HOME export or exec in shim") + t.Fatalf("composer symlink not created: %v", err) } - if homeIdx > 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/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go new file mode 100644 index 0000000..76f5b4c --- /dev/null +++ b/internal/selfupdate/selfupdate.go @@ -0,0 +1,157 @@ +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 +} + +// 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 := githubAPIURL + "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 "", fmt.Errorf("cannot read GitHub API response: %w", 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 { + return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH) +} + +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/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) + } +} 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") - } -} diff --git a/internal/tools/shims.go b/internal/tools/shims.go new file mode 100644 index 0000000..a803a16 --- /dev/null +++ b/internal/tools/shims.go @@ -0,0 +1,86 @@ +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" "$@" +` + +// 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 +} + diff --git a/internal/tools/tool.go b/internal/tools/tool.go new file mode 100644 index 0000000..940592f --- /dev/null +++ b/internal/tools/tool.go @@ -0,0 +1,179 @@ +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 automatically or manually + ExposureSymlink // symlink internal/bin/X -> bin/X + ExposureShim // custom bash shim script +) + +// Tool describes a managed binary. +type Tool struct { + Name string // set automatically from registry key + 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 registry = map[string]*Tool{ + "php": { + DisplayName: "PHP", + AutoExpose: true, + Exposure: ExposureShim, + InternalPath: func() string { + return filepath.Join(config.PhpVersionDir(globalPHPVersion()), "php") + }, + WriteShim: writePhpShim, + }, + "frankenphp": { + DisplayName: "FrankenPHP", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return filepath.Join(config.PhpVersionDir(globalPHPVersion()), "frankenphp") + }, + }, + "composer": { + DisplayName: "Composer", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return config.ComposerPharPath() + }, + }, + "mago": { + DisplayName: "Mago", + AutoExpose: true, + Exposure: ExposureSymlink, + InternalPath: func() string { + return config.MagoPath() + }, + }, + "colima": { + DisplayName: "Colima", + AutoExpose: false, + Exposure: ExposureSymlink, + InternalPath: func() string { + return config.ColimaPath() + }, + }, +} + +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 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 registry { + 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) + 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) + } + 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 registry { + 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..df77bf9 --- /dev/null +++ b/internal/tools/tool_test.go @@ -0,0 +1,236 @@ +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 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. + 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(registry) { + t.Errorf("List() returned %d tools, want %d", len(list), len(registry)) + } + // 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) + } + } +} + +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) + } + } +} diff --git a/scripts/e2e/composer.sh b/scripts/e2e/composer.sh index c549b6c..ed4b02f 100755 --- a/scripts/e2e/composer.sh +++ b/scripts/e2e/composer.sh @@ -5,25 +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" -ls -la ~/.pv/data/composer.phar -test -f ~/.pv/data/composer.phar || { echo "FAIL: composer.phar not found"; exit 1; } +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" -COMPOSER_HOME_OUTPUT=$(composer config --global home 2>/dev/null) -echo " COMPOSER_HOME = $COMPOSER_HOME_OUTPUT" +# ── 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 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" -COMPOSER_CACHE_OUTPUT=$(composer config --global cache-dir 2>/dev/null) -echo " COMPOSER_CACHE_DIR = $COMPOSER_CACHE_OUTPUT" +# ── 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 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 ────────────────────────────── @@ -78,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" 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/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" 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" diff --git a/scripts/e2e/verify-install.sh b/scripts/e2e/verify-install.sh index f436e1a..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,9 +32,9 @@ 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; } -test -f ~/.pv/data/composer.phar || { echo "FAIL: composer.phar not found"; 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" test -d ~/.pv/composer || { echo "FAIL: ~/.pv/composer not created"; exit 1; }