diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index fcb2cc8..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,310 +0,0 @@ -# AGENTS.md - -Guide for AI coding agents working in the `pv` codebase. - -## What is pv - -`pv` is a local development server manager powered by FrankenPHP (Caddy + embedded PHP). It manages FrankenPHP instances serving projects under `.test` domains with HTTPS, supporting multiple PHP versions simultaneously. Written in Go using Cobra for CLI. - -## Build, Test & Lint Commands - -```bash -# Build -go build -o pv . - -# Run all tests -go test ./... - -# Run tests for a single package -go test ./internal/registry/ -go test ./cmd/ - -# Run a single test or matching pattern -go test ./cmd/ -run TestLink -go test ./internal/phpenv/ -run TestResolveVersion - -# Verbose output -go test ./... -v - -# Test with coverage -go test ./... -cover - -# Format code (use goimports, not gofmt) -goimports -w . - -# Lint (if golangci-lint is available) -golangci-lint run -``` - -## Architecture Overview - -``` -main.go # Entry point → calls cmd.Execute() -cmd/ # Cobra commands (user-facing CLI) -internal/ - config/ # ~/.pv/ paths & settings - registry/ # Project registry (JSON) - phpenv/ # PHP version management - caddy/ # Caddyfile generation - server/ # Process management (FrankenPHP + DNS) - binaries/ # Binary downloads - detection/ # Project type detection - setup/ # Installation helpers -``` - -See `CLAUDE.md` for detailed architecture, directory layout, and multi-version architecture. - -## Code Style Guidelines - -### Imports - -Use standard Go import order (automatically handled by `goimports`): -```go -import ( - // 1. Standard library (alphabetical) - "encoding/json" - "fmt" - "os" - - // 2. External packages (alphabetical) - "github.com/spf13/cobra" - - // 3. Internal packages (alphabetical) - "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/registry" -) -``` - -### Formatting - -- Use `goimports` (not `gofmt`) — it handles imports + formatting -- Tabs for indentation (Go standard) -- No trailing whitespace -- One declaration per line - -### Types - -**Struct definitions:** -```go -// JSON-serializable structs use tags -type Project struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - PHP string `json:"php,omitempty"` // omitempty for optional -} - -// Internal structs (no serialization) use simple form -type siteData struct { - Name string - Path string - RootPath string -} -``` - -**Always use pointer receivers for methods:** -```go -func (r *Registry) Add(p Project) error { ... } -func (s *Settings) Save() error { ... } -``` - -### Naming Conventions - -**Variables:** -- Short names in local scope: `reg`, `p`, `s`, `v`, `err` -- Full names for package-level/exported: `linkName`, `Settings`, `GlobalVersion` -- Single-letter or short receivers: `r` for Registry, `s` for Settings - -**Functions:** -- Action verbs: `Add`, `Remove`, `Save`, `Start`, `Stop`, `Install` -- Query verbs: `Find`, `List`, `IsInstalled`, `IsRunning` -- Get/Set: `GlobalVersion`, `SetGlobal` -- Generate: `GenerateSiteConfig`, `GenerateCaddyfile` -- Resolve: `ResolveVersion`, `resolveRoot` - -**Tests:** -```go -// Format: Test{FunctionName}_{Scenario} -func TestAdd_ToEmpty(t *testing.T) { ... } -func TestAdd_Duplicate(t *testing.T) { ... } -func TestRemove_NonExistent(t *testing.T) { ... } -``` - -**Constants:** -- UPPER_SNAKE_CASE for config: `DNSPort = 10053` -- camelCase for templates (unexported): `laravelTmpl`, `mainCaddyfile` - -### Error Handling - -**Always return errors as last value:** -```go -func Load() (*Registry, error) { ... } -func (r *Registry) Save() error { ... } -``` - -**Wrap errors with context using fmt.Errorf + %w:** -```go -if err := registry.Load(); err != nil { - return fmt.Errorf("cannot load registry: %w", err) -} -``` - -**Create new errors with fmt.Errorf (no %w):** -```go -if name == "" { - return fmt.Errorf("project name cannot be empty") -} -``` - -**Check errors immediately:** -```go -data, err := os.ReadFile(path) -if err != nil { - if os.IsNotExist(err) { - return &Registry{}, nil // Special case first - } - return nil, err // General error -} -``` - -**No naked returns — always explicit:** -```go -if err != nil { - return nil, err // Explicit nil, explicit error -} -return ®, nil // Explicit value, explicit nil -``` - -### Comments - -**Godoc style for exported functions:** -```go -// InstalledVersions returns all PHP versions that have been installed. -// It scans ~/.pv/php/ for directories containing a frankenphp binary. -func InstalledVersions() ([]string, error) { ... } -``` - -- First sentence is summary (appears in godoc) -- Explain parameters, return values, and special cases -- Full sentences with periods for godoc comments -- No period for short inline comments - -### Testing Patterns - -**CRITICAL: Always isolate tests with t.TempDir() + t.Setenv:** -```go -func TestSomething(t *testing.T) { - home := t.TempDir() - t.Setenv("HOME", home) - // All ~/.pv/ operations now go to temp dir -} -``` - -**Helper functions must use t.Helper():** -```go -func scaffold(t *testing.T) string { - t.Helper() // Makes failures point to caller - home := t.TempDir() - t.Setenv("HOME", home) - return home -} -``` - -**Build fresh cobra commands per test:** -```go -func newLinkCmd() *cobra.Command { - var name string // Local variable - root := &cobra.Command{Use: "pv"} - link := &cobra.Command{ - Use: "link", - RunE: func(cmd *cobra.Command, args []string) error { - linkName = name // Sync to package var - return linkCmd.RunE(cmd, args) - }, - } - link.Flags().StringVar(&name, "name", "", "") - root.AddCommand(link) - return root -} -``` - -**Table-driven tests for multiple cases:** -```go -func TestPortForVersion(t *testing.T) { - tests := []struct { - version string - want int - }{ - {"8.3", 8830}, - {"8.4", 8840}, - } - for _, tt := range tests { - t.Run(tt.version, func(t *testing.T) { - got := PortForVersion(tt.version) - if got != tt.want { - t.Errorf("got %d, want %d", got, tt.want) - } - }) - } -} -``` - -**Standard assertions:** -```go -if err != nil { - t.Fatalf("Function() error = %v", err) // Fatal stops -} -if got != want { - t.Errorf("got %q, want %q", got, want) // Error continues -} -``` - -### File Operations - -**Always use filepath package:** -```go -path := filepath.Join(config.SitesDir(), name+".caddy") // NOT string concat -name := filepath.Base(absPath) -dir := filepath.Dir(destPath) -``` - -**Standard permissions:** -```go -os.WriteFile(path, data, 0644) // Regular files -os.MkdirAll(dir, 0755) // Directories -os.Chmod(path, 0755) // Executables -``` - -**Atomic file writes (temp + rename):** -```go -tmp, err := os.CreateTemp(dir, ".pv-download-*") -// ... write to tmp ... -if err := tmp.Close(); err != nil { - os.Remove(tmp.Name()) - return err -} -if err := os.Rename(tmp.Name(), destPath); err != nil { - os.Remove(tmp.Name()) - return err -} -``` - -## Key Principles - -1. **Test isolation via HOME redirection** — `t.Setenv("HOME", t.TempDir())` -2. **Fresh cobra commands for tests** — Avoid state leakage -3. **Error wrapping with context** — `fmt.Errorf("...: %w", err)` -4. **No interfaces** — All concrete types, no mocking -5. **Helper functions marked with t.Helper()** — Better error messages -6. **Atomic file operations** — temp file + rename -7. **Pointer receivers everywhere** — Consistency -8. **Standard library first** — Minimal external dependencies -9. **Explicit returns** — No naked returns -10. **Use goimports, not gofmt** — Handles imports + formatting - -## Testing Strategy - -- **Unit tests** (`go test ./...`): Run locally with filesystem isolation via `t.Setenv("HOME", t.TempDir())`. Use fake binaries (bash scripts) when needed. -- **E2E tests** (`.github/workflows/e2e.yml` + `scripts/e2e/`): Run on GitHub Actions for real binary execution, network calls, DNS, HTTPS. Add scripts to `scripts/e2e/` for integration scenarios. - -When your feature needs real PHP/Composer/FrankenPHP/DNS/HTTPS, create an E2E test script. diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3535dec..7d06987 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,9 +20,10 @@ Build version is set via `go build -ldflags "-X github.com/prvious/pv/cmd.versio ## Command conventions - **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. +- **Subpackage layout**: tool/service/daemon commands live in `internal/commands//` (e.g., `internal/commands/mago/install.go`). Each group has a `register.go` with a `Register(parent *cobra.Command)` function that wires all commands onto rootCmd. Bridge files in `cmd/` (e.g., `cmd/mago.go`) call `Register(rootCmd)` in `init()`. +- **Core/orchestrator commands** (`install`, `update`, `uninstall`, `link`, `start`, `stop`, etc.) remain in `cmd/` as flat files. +- **Cross-package calls**: `register.go` exports `Run*()` helpers (e.g., `php.RunInstall(args)`) for orchestrators to call sub-tool RunE functions. - **Always use `RunE`** (not `Run`) so errors propagate. -- **Command files are named `_.go`** (e.g., `mago_install.go`, `service_add.go`). ## Tool command rules @@ -32,9 +33,9 @@ Every managed tool (php, mago, composer, colima) follows a strict five-command p |---------|-------------|-------------------| | `: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/` | +| `:install` | Orchestrates `:download` then `tools.Expose()` | `internal/commands//` — delegates only | +| `:update` | Redownloads, re-exposes if `tools.IsExposed()` | `internal/commands//` + `internal/` | +| `:uninstall` | Unexposes + removes binary files | `internal/commands//` + `internal/tools/` | **Hard rules:** 1. `:install` MUST delegate to `:download` RunE — never inline download logic in `cmd/`. @@ -59,15 +60,52 @@ Every managed tool (php, mago, composer, colima) follows a strict five-command p ## UI rules -All user-facing operations MUST use `internal/ui/` helpers. Never use raw `fmt.Print` for status output. +### Stack overview -- **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)`. +The CLI uses a layered Charm stack: +- **fang** (`charm.land/fang/v2`) — wraps Cobra. Handles help pages, usage text, error display (with `ERROR` badge), version flag, and command spacing. Configured in `cmd/root.go` via `fang.Execute()`. +- **huh** (`charm.land/huh/v2`) — interactive forms (multi-select, text input, confirm). Used for `setup` wizard and any future interactive prompts. +- **lipgloss** (`charm.land/lipgloss/v2`) — low-level styling. Used inside `internal/ui/` helpers. Never import v1 (`github.com/charmbracelet/lipgloss`). +- **`internal/ui/`** — spinners, progress bars, status output (✓/✗), tables, trees. All user-facing status output goes through these helpers. + +### What fang handles (do NOT reimplement) + +- **Help/usage text** — fang styles it. Never set `Long` to replicate usage info. Put usage examples in the `Example` field (fang syntax-highlights them). +- **Error display** — fang shows errors with a styled `ERROR` badge. Never manually print errors and `os.Exit(1)`. Return `error` from `RunE` and let fang handle it. +- **`SilenceUsage` / `SilenceErrors`** — fang sets these globally. Never set them on individual commands. +- **Spacing/padding** — fang manages whitespace around help and error output. Don't add `fmt.Fprintln(os.Stderr)` for visual spacing around errors. +- **Version flag** — provided via `fang.WithVersion()`. Don't add a manual `--version` flag. + +### What `internal/ui/` handles (always use these) + +- **Long operations**: `ui.Step(label, fn)` — spinner, then `✓ result` or `✗ error`. +- **Downloads**: `ui.StepProgress(label, fn)` — progress bar with percentage. +- **Multi-step commands**: `ui.Header(version)` at start, `ui.Footer(start, docsURL)` at end. +- **Lists/tables**: `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`). +### Error handling pattern + +- **Simple errors**: return `fmt.Errorf(...)` — fang displays it with styled `ERROR` badge. +- **After `ui.Step` / `ui.StepProgress`**: these already print `✗` on failure and return `ui.ErrAlreadyPrinted`. The custom fang error handler in `cmd/root.go` skips re-display for this sentinel. +- **Never use the sandwich pattern**: don't do `fmt.Fprintln` + `ui.Fail()` + `cmd.SilenceUsage = true` + `return ErrAlreadyPrinted`. Just return the error. + +### Interactive forms + +- Use **huh** (`charm.land/huh/v2`) for any interactive user input (multi-select, text fields, confirmations). +- Never use raw `fmt.Scan` / `bufio.Scanner` for interactive input. + +### Hard don'ts + +1. **Errors**: always `return fmt.Errorf(...)` — fang displays them. Never `fmt.Print` an error manually. +2. **Status output**: use `ui.*` helpers (`ui.Success`, `ui.Fail`, `ui.Subtle`, `ui.Step`, etc.) — never raw `fmt.Print*` for new code. Legacy uses remain in older commands. +3. Never import lipgloss v1 (`github.com/charmbracelet/lipgloss`). Always use `charm.land/lipgloss/v2`. +4. Never set `SilenceUsage` or `SilenceErrors` on commands — fang owns this. +5. Never add `--version` flags — fang provides this. +6. Put usage examples in `Example:` field, not `Long:` — fang syntax-highlights `Example`. +7. Don't add `fmt.Fprintln(os.Stderr)` for blank-line spacing around errors — fang handles spacing. + ## Import cycle: phpenv ↔ tools `phpenv` and `tools` cannot import each other. This is resolved via callback: @@ -92,4 +130,4 @@ All user-facing operations MUST use `internal/ui/` helpers. Never use raw `fmt.P - 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`. +- Service commands use `service:action` format. New services need: implementation in `internal/services/`, command in `internal/commands/service/`. diff --git a/cmd/colima.go b/cmd/colima.go new file mode 100644 index 0000000..27b4781 --- /dev/null +++ b/cmd/colima.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/colima" + +func init() { + colima.Register(rootCmd) +} diff --git a/cmd/composer.go b/cmd/composer.go new file mode 100644 index 0000000..e25b4ef --- /dev/null +++ b/cmd/composer.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/composer" + +func init() { + composer.Register(rootCmd) +} diff --git a/cmd/daemon.go b/cmd/daemon.go deleted file mode 100644 index b57f0a2..0000000 --- a/cmd/daemon.go +++ /dev/null @@ -1,92 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/prvious/pv/internal/daemon" - "github.com/prvious/pv/internal/ui" - "github.com/spf13/cobra" -) - -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) - - if err := ui.Step("Installing pv daemon...", func() (string, error) { - cfg := daemon.DefaultPlistConfig() - cfg.RunAtLoad = true - - if err := daemon.Install(cfg); err != nil { - return "", fmt.Errorf("cannot install daemon: %w", err) - } - - // Load the daemon so it starts immediately. - if err := daemon.Load(); err != nil { - return "", fmt.Errorf("cannot start daemon: %w", err) - } - - return "Daemon installed (starts automatically on login)", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil - }, -} - -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) - - if err := ui.Step("Uninstalling pv daemon...", func() (string, error) { - // Unload if loaded. - if daemon.IsLoaded() { - if err := daemon.Unload(); err != nil { - return "", fmt.Errorf("cannot stop daemon: %w", err) - } - } - - if err := daemon.Uninstall(); err != nil { - return "", fmt.Errorf("cannot uninstall daemon: %w", err) - } - - return "Daemon uninstalled", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil - }, -} - -var daemonRestartCmd = &cobra.Command{ - Use: "daemon:restart", - Short: "Restart the pv daemon", - RunE: func(cmd *cobra.Command, args []string) error { - if !daemon.IsLoaded() { - ui.Subtle("Daemon is not running") - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted - } - - return ui.Step("Restarting pv daemon...", func() (string, error) { - if err := daemon.Restart(); err != nil { - return "", fmt.Errorf("cannot restart daemon: %w", err) - } - return "Daemon restarted", nil - }) - }, -} - -func init() { - rootCmd.AddCommand(daemonEnableCmd) - rootCmd.AddCommand(daemonDisableCmd) - rootCmd.AddCommand(daemonRestartCmd) -} diff --git a/cmd/daemon_cmds.go b/cmd/daemon_cmds.go new file mode 100644 index 0000000..22750e8 --- /dev/null +++ b/cmd/daemon_cmds.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/daemon" + +func init() { + daemon.Register(rootCmd) +} diff --git a/cmd/doctor.go b/cmd/doctor.go index 2496df4..0ba5de0 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -16,6 +16,7 @@ import ( "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" ) @@ -27,8 +28,10 @@ type check struct { } var doctorCmd = &cobra.Command{ - Use: "doctor", - Short: "Diagnose pv installation health", + Use: "doctor", + GroupID: "core", + Short: "Diagnose pv installation health", + Example: " pv doctor", RunE: func(cmd *cobra.Command, args []string) error { settings, err := config.LoadSettings() if err != nil { @@ -55,35 +58,33 @@ var doctorCmd = &cobra.Command{ allChecks = append(allChecks, svcChecks) } - fmt.Println("pv doctor") - fmt.Println() + ui.SectionHeader("pv doctor") passed, failed := 0, 0 for _, section := range allChecks { - fmt.Println(section.Name) + ui.SectionHeader(section.Name) for _, c := range section.Checks { if c.Status { - fmt.Printf(" ✓ %s\n", c.Name) + ui.Success(c.Name) passed++ } else { - fmt.Printf(" ✗ %s\n", c.Name) + ui.Fail(c.Name) if c.Message != "" { - fmt.Printf(" %s\n", c.Message) + ui.FailDetail(c.Message) } if c.Fix != "" { - fmt.Printf(" → Run: %s\n", c.Fix) + ui.FailDetail("→ Run: " + c.Fix) } failed++ } } - fmt.Println() } - fmt.Printf("%d passed, %d issues found\n", passed, failed) - if failed > 0 { - return fmt.Errorf("%d issues found", failed) + ui.Fail(fmt.Sprintf("%d passed, %d issues found", passed, failed)) + return ui.ErrAlreadyPrinted } + ui.Success(fmt.Sprintf("%d passed, no issues found", passed)) return nil }, } @@ -243,7 +244,7 @@ func runEnvironmentChecks() sectionResult { Name: "FrankenPHP symlink", Status: false, Message: fmt.Sprintf("broken symlink → %s", target), - Fix: "pv php:use ", + Fix: "pv php:use [version]", }) } } else if isExecutable(fpLink) { diff --git a/cmd/env.go b/cmd/env.go index 39ca1bf..993dda6 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -9,15 +9,12 @@ import ( ) var envCmd = &cobra.Command{ - Use: "env", - Short: "Print shell configuration for pv", - Long: `Print shell commands to configure PATH for pv. - -Add this to your shell config (.zshrc, .bashrc, config.fish): - - eval "$(pv env)" - -Or run it directly to configure your current session.`, + Use: "env", + GroupID: "core", + Short: "Print shell configuration for pv", + Long: "Print shell commands to configure PATH for pv.", + Example: `# Add to your .zshrc or .bashrc +eval "$(pv env)"`, RunE: func(cmd *cobra.Command, args []string) error { shell := detectShell() home, err := os.UserHomeDir() diff --git a/cmd/install.go b/cmd/install.go index 34c818e..5f030cd 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,11 +1,16 @@ package cmd import ( + "errors" "fmt" "os" "strings" "time" + "github.com/prvious/pv/internal/commands/composer" + "github.com/prvious/pv/internal/commands/mago" + "github.com/prvious/pv/internal/commands/php" + "github.com/prvious/pv/internal/commands/service" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/services" "github.com/prvious/pv/internal/setup" @@ -75,19 +80,25 @@ func parseWith(raw string) (withSpec, error) { } var installCmd = &cobra.Command{ - Use: "install", - Short: "Non-interactive setup — installs PHP, Composer, and configures the environment", + Use: "install", + GroupID: "core", + 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. +Colima is installed automatically when you add your first service.`, + Example: `# Install with defaults +pv install -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]"`, +# Specify a custom TLD +pv install --tld=test + +# Choose a specific PHP version and optional tools +pv install --with="php:8.2,mago" + +# Include backing services +pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() @@ -101,11 +112,7 @@ Examples: } if setup.IsAlreadyInstalled() && !forceInstall { - fmt.Fprintln(os.Stderr) - ui.Fail("pv is already installed") - ui.FailDetail("Run with --force to reinstall") - fmt.Fprintln(os.Stderr) - return ui.ErrAlreadyPrinted + return fmt.Errorf("pv is already installed, run with --force to reinstall") } ui.Header(version) @@ -121,7 +128,7 @@ Examples: } return fmt.Sprintf("macOS %s", setup.PlatformLabel()), nil }); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 2: Create directory structure and save settings. @@ -139,7 +146,7 @@ Examples: } return "Directories created", nil }); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 3: Install PHP (non-negotiable). @@ -147,25 +154,25 @@ Examples: if spec.phpVersion != "" { phpArgs = []string{spec.phpVersion} } - if err := phpInstallCmd.RunE(phpInstallCmd, phpArgs); err != nil { - return ui.ErrAlreadyPrinted + if err := php.RunInstall(phpArgs); err != nil { + return err } // Step 4: Install Composer (non-negotiable). - if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { - return ui.ErrAlreadyPrinted + if err := composer.RunInstall(); err != nil { + return err } // Step 5: Install Mago (opt-in via --with). if spec.mago { - if err := magoInstallCmd.RunE(magoInstallCmd, nil); err != nil { - return ui.ErrAlreadyPrinted + if err := mago.RunInstall(); err != nil { + return err } } // Step 6: Finalize (Caddyfile, DNS, CA trust, shell PATH). if err := bootstrapFinalize(installTLD); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 7: Install services from --with. @@ -174,8 +181,10 @@ Examples: if svc.version != "" { svcArgs = append(svcArgs, svc.version) } - if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { - fmt.Fprintf(os.Stderr, " %s Service %s failed: %v\n", ui.Red.Render("!"), svc.name, err) + if err := service.RunAdd(svcArgs); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) + } } } @@ -196,7 +205,6 @@ func shortPath(path string) string { func init() { 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(&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 8c948e9..4f2db29 100644 --- a/cmd/install_test.go +++ b/cmd/install_test.go @@ -15,7 +15,7 @@ func newInstallCmd() *cobra.Command { root := &cobra.Command{Use: "pv", SilenceErrors: true, SilenceUsage: true} install := &cobra.Command{ - Use: "install", + Use: "install", RunE: func(cmd *cobra.Command, args []string) error { forceInstall = force installTLD = tld diff --git a/cmd/link.go b/cmd/link.go index 4b8cb72..81a5fc3 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -18,9 +18,18 @@ import ( var linkName string var linkCmd = &cobra.Command{ - Use: "link [path]", - Short: "Link a project directory", - Args: cobra.MaximumNArgs(1), + Use: "link [path]", + GroupID: "core", + Short: "Link a project directory", + Example: `# Link the current directory +pv link + +# Link a specific path +pv link ~/Code/myapp + +# Link with a custom name +pv link --name=myapp ~/Code/myapp`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { path := "." if len(args) > 0 { @@ -67,14 +76,7 @@ var linkCmd = &cobra.Command{ project := registry.Project{Name: name, Path: absPath, Type: projectType, PHP: phpVersion} if existing := reg.Find(name); existing != nil { - domain := "https://" + name + "." + settings.TLD - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("%s is already linked", ui.Purple.Bold(true).Render(domain))) - ui.FailDetail(fmt.Sprintf("Path: %s", existing.Path)) - ui.FailDetail("To re-link, run: pv unlink " + name + " && pv link " + path) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("%s is already linked at %s\nTo re-link, run: pv unlink %s && pv link %s", name, existing.Path, name, path) } if err := reg.Add(project); err != nil { return err @@ -116,7 +118,7 @@ var linkCmd = &cobra.Command{ if server.IsRunning() { if err := server.ReconfigureServer(); err != nil { - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Red.Render("!"), ui.Muted.Render(fmt.Sprintf("Could not reconfigure server: %v", err))) + ui.Fail(fmt.Sprintf("Could not reconfigure server: %v", err)) } if phpVersion != "" && phpVersion != globalPHP { ui.Subtle("Restart the server to serve this project: pv stop && pv start") diff --git a/cmd/link_services.go b/cmd/link_services.go index 5548785..da3f991 100644 --- a/cmd/link_services.go +++ b/cmd/link_services.go @@ -20,7 +20,7 @@ func detectAndBindServices(projectPath, projectName string, reg *registry.Regist return } - dbName := sanitizeProjectName(projectName) + dbName := services.SanitizeProjectName(projectName) var detected []string var suggestions []string var needsEnvUpdate bool diff --git a/cmd/list.go b/cmd/list.go index d574e63..a8e73ee 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -12,6 +12,7 @@ import ( var listCmd = &cobra.Command{ Use: "list", + GroupID: "core", Aliases: []string{"ls"}, Short: "List linked projects", RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/log.go b/cmd/log.go index f024c53..be2767e 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -21,9 +21,18 @@ var ( ) var logCmd = &cobra.Command{ - Use: "log [site]", - Short: "Tail the FrankenPHP log", - Args: cobra.MaximumNArgs(1), + Use: "log [site]", + GroupID: "core", + Short: "Tail the FrankenPHP log", + Example: `# Tail all logs +pv log + +# Follow logs in real time +pv log -f + +# Show last 50 lines for a specific site +pv log myapp -n 50`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { logPath := config.CaddyLogPath() if logError { diff --git a/cmd/mago.go b/cmd/mago.go new file mode 100644 index 0000000..e43d62e --- /dev/null +++ b/cmd/mago.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/mago" + +func init() { + mago.Register(rootCmd) +} diff --git a/cmd/php.go b/cmd/php.go new file mode 100644 index 0000000..566d46c --- /dev/null +++ b/cmd/php.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/php" + +func init() { + php.Register(rootCmd) +} diff --git a/cmd/php_remove.go b/cmd/php_remove.go deleted file mode 100644 index 5eb926a..0000000 --- a/cmd/php_remove.go +++ /dev/null @@ -1,67 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "regexp" - - "github.com/prvious/pv/internal/phpenv" - "github.com/prvious/pv/internal/registry" - "github.com/prvious/pv/internal/ui" - "github.com/spf13/cobra" -) - -var phpRemoveCmd = &cobra.Command{ - Use: "php:remove ", - Short: "Remove an installed PHP version", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - version := args[0] - if !regexp.MustCompile(`^\d+\.\d+$`).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 - } - - // Check if any linked projects depend on this version. - reg, err := registry.Load() - if err == nil { - globalV, _ := phpenv.GlobalVersion() - for _, p := range reg.List() { - v := p.PHP - if v == "" { - v = globalV - } - if v == version { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Cannot remove PHP %s", ui.Bold.Render(version))) - ui.FailDetail(fmt.Sprintf("Project %s depends on it", ui.Bold.Render(p.Name))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted - } - } - } - - fmt.Fprintln(os.Stderr) - - if err := ui.Step("Removing PHP "+version+"...", func() (string, error) { - if err := phpenv.Remove(version); err != nil { - return "", err - } - return fmt.Sprintf("PHP %s removed", version), nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil - }, -} - -func init() { - rootCmd.AddCommand(phpRemoveCmd) -} diff --git a/cmd/restart.go b/cmd/restart.go index c617850..0a1cd6e 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -2,8 +2,8 @@ package cmd import ( "fmt" - "os" + daemoncmds "github.com/prvious/pv/internal/commands/daemon" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/server" "github.com/prvious/pv/internal/ui" @@ -11,35 +11,26 @@ import ( ) var restartCmd = &cobra.Command{ - Use: "restart", - Short: "Restart or reload the pv server", + Use: "restart", + GroupID: "server", + Short: "Restart or reload the pv server", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr) - // Daemon mode — delegate to daemon:restart. if daemon.IsLoaded() { - return daemonRestartCmd.RunE(daemonRestartCmd, nil) + return daemoncmds.RunRestart() } // Foreground mode — reload config via admin API. if !server.IsRunning() { - ui.Subtle("pv is not running") - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("pv is not running") } - if err := ui.Step("Reloading server configuration...", func() (string, error) { + return ui.Step("Reloading server configuration...", func() (string, error) { if err := server.ReconfigureServer(); err != nil { return "", fmt.Errorf("reconfigure failed: %w", err) } return "Configuration reloaded", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil + }) }, } diff --git a/cmd/root.go b/cmd/root.go index ae7c742..07e3084 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,10 +1,14 @@ package cmd import ( + "context" "errors" - "fmt" + "io" "os" + "charm.land/fang/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -15,19 +19,42 @@ import ( var version = "dev" var rootCmd = &cobra.Command{ - Use: "pv", - Short: "Local dev server manager powered by FrankenPHP", - Version: version, - SilenceErrors: true, + Use: "pv", + Short: "Local dev server manager powered by FrankenPHP", +} + +func init() { + rootCmd.AddGroup( + &cobra.Group{ID: "core", Title: "Core"}, + &cobra.Group{ID: "server", Title: "Server"}, + &cobra.Group{ID: "php", Title: "PHP"}, + &cobra.Group{ID: "composer", Title: "Composer"}, + &cobra.Group{ID: "mago", Title: "Mago"}, + &cobra.Group{ID: "colima", Title: "Colima"}, + &cobra.Group{ID: "service", Title: "Services"}, + &cobra.Group{ID: "daemon", Title: "Daemon"}, + ) } func Execute() { - if err := rootCmd.Execute(); err != nil { - // If the error was already printed with styled output, just exit. - if errors.Is(err, ui.ErrAlreadyPrinted) { - os.Exit(1) - } - fmt.Fprintln(os.Stderr, err) + if err := fang.Execute(context.Background(), rootCmd, + fang.WithVersion(version), + fang.WithColorSchemeFunc(pvColorScheme), + fang.WithErrorHandler(pvErrorHandler), + ); err != nil { os.Exit(1) } } + +func pvColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + cs := fang.DefaultColorScheme(c) + cs.Title = charmtone.Charple + return cs +} + +func pvErrorHandler(w io.Writer, styles fang.Styles, err error) { + if errors.Is(err, ui.ErrAlreadyPrinted) { + return + } + fang.DefaultErrorHandler(w, styles, err) +} diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..82bcef7 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,7 @@ +package cmd + +import "github.com/prvious/pv/internal/commands/service" + +func init() { + service.Register(rootCmd) +} diff --git a/cmd/service_logs.go b/cmd/service_logs.go deleted file mode 100644 index c85eea5..0000000 --- a/cmd/service_logs.go +++ /dev/null @@ -1,52 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - - "github.com/prvious/pv/internal/registry" - "github.com/prvious/pv/internal/ui" - "github.com/spf13/cobra" -) - -var serviceLogsCmd = &cobra.Command{ - Use: "service:logs ", - Short: "Tail container logs for a service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - - reg, err := registry.Load() - if err != nil { - return fmt.Errorf("cannot load registry: %w", err) - } - - instance := reg.FindService(key) - if instance == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted - } - - if instance.ContainerID == "" { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s is not running", ui.Bold.Render(key))) - ui.FailDetail(fmt.Sprintf("Start it first: pv service:start %s", key)) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted - } - - // Docker SDK: ContainerLogs with Follow=true - // This would stream logs to stdout. - fmt.Fprintf(os.Stderr, "Tailing logs for %s (container: %s)...\n", key, instance.ContainerID) - - return nil - }, -} - -func init() { - rootCmd.AddCommand(serviceLogsCmd) -} diff --git a/cmd/service_status.go b/cmd/service_status.go deleted file mode 100644 index 1215b47..0000000 --- a/cmd/service_status.go +++ /dev/null @@ -1,78 +0,0 @@ -package cmd - -import ( - "fmt" - "os" - "strings" - - "github.com/prvious/pv/internal/config" - "github.com/prvious/pv/internal/registry" - "github.com/prvious/pv/internal/services" - "github.com/prvious/pv/internal/ui" - "github.com/spf13/cobra" -) - -var serviceStatusCmd = &cobra.Command{ - Use: "service:status ", - Short: "Show detailed status for a service", - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - key := args[0] - - reg, err := registry.Load() - if err != nil { - return fmt.Errorf("cannot load registry: %w", err) - } - - instance := reg.FindService(key) - if instance == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted - } - - // Parse service name and version from key. - svcName := key - version := "latest" - if idx := strings.Index(key, ":"); idx > 0 { - svcName = key[:idx] - version = key[idx+1:] - } - - svc, err := services.Lookup(svcName) - if err != nil { - return err - } - - status := "stopped" - if instance.ContainerID != "" { - status = "running" - } - - dataDir := config.ServiceDataDir(svcName, version) - projects := reg.ProjectsUsingService(svcName) - - fmt.Fprintln(os.Stderr) - fmt.Fprintf(os.Stderr, " %s\n", ui.Purple.Bold(true).Render(fmt.Sprintf("%s %s", svc.DisplayName(), version))) - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Status"), status) - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Container"), svc.ContainerName(version)) - fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Port"), instance.Port) - if instance.ConsolePort > 0 { - fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Console"), instance.ConsolePort) - } - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Data"), dataDir) - - if len(projects) > 0 { - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Projects"), strings.Join(projects, ", ")) - } - fmt.Fprintln(os.Stderr) - - return nil - }, -} - -func init() { - rootCmd.AddCommand(serviceStatusCmd) -} diff --git a/cmd/setup.go b/cmd/setup.go index 79db255..4fa0a83 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1,13 +1,17 @@ package cmd import ( + "errors" "fmt" "net/http" "os" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/prvious/pv/internal/binaries" + "github.com/prvious/pv/internal/commands/composer" + "github.com/prvious/pv/internal/commands/mago" + "github.com/prvious/pv/internal/commands/service" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/services" @@ -17,8 +21,9 @@ import ( ) var setupCmd = &cobra.Command{ - Use: "setup", - Short: "Interactive setup wizard — choose PHP versions, tools, and services", + Use: "setup", + GroupID: "core", + Short: "Interactive setup wizard — choose PHP versions, tools, and services", RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() client := &http.Client{} @@ -119,7 +124,6 @@ var setupCmd = &cobra.Command{ ) if err := form.Run(); err != nil { - cmd.SilenceUsage = true return err } @@ -163,7 +167,9 @@ var setupCmd = &cobra.Command{ } return fmt.Sprintf("PHP %s installed", v), nil }); err != nil { - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Red.Render("!"), fmt.Sprintf("PHP %s failed: %v", v, err)) + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("PHP %s failed: %v", v, err)) + } } } @@ -176,8 +182,10 @@ var setupCmd = &cobra.Command{ } // 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) + if err := composer.RunInstall(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Composer failed: %v", err)) + } } // Install optional tools (Colima is lazy-installed via service:add). @@ -187,14 +195,16 @@ var setupCmd = &cobra.Command{ } if toolSet["mago"] { - if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Mago failed: %v\n", ui.Red.Render("!"), err) + if err := mago.RunDownload(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Mago failed: %v", 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) + ui.Fail(fmt.Sprintf("Tool exposure failed: %v", err)) } // Save version manifest. @@ -204,7 +214,7 @@ var setupCmd = &cobra.Command{ 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) + ui.Fail(fmt.Sprintf("Cannot save version manifest: %v", saveErr)) } } @@ -222,8 +232,10 @@ var setupCmd = &cobra.Command{ 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) + if err := service.RunAdd(svcArgs); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Service %s failed: %v", name, err)) + } } } } diff --git a/cmd/start.go b/cmd/start.go index 1545a7e..f8d193b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "time" "github.com/prvious/pv/internal/config" @@ -18,8 +17,9 @@ var ( ) var startCmd = &cobra.Command{ - Use: "start", - Short: "Start the pv server (DNS + FrankenPHP)", + Use: "start", + GroupID: "server", + Short: "Start the pv server (DNS + FrankenPHP)", RunE: func(cmd *cobra.Command, args []string) error { if startBackground { return startDaemon() @@ -30,11 +30,7 @@ var startCmd = &cobra.Command{ func startFG() error { if server.IsRunning() { - fmt.Fprintln(os.Stderr) - ui.Fail("pv is already running") - ui.FailDetail("PID file exists and process is alive") - fmt.Fprintln(os.Stderr) - return ui.ErrAlreadyPrinted + return fmt.Errorf("pv is already running (PID file exists and process is alive)") } settings, err := config.LoadSettings() @@ -50,9 +46,7 @@ func startDaemon() error { if daemon.IsLoaded() { pid, err := daemon.GetPID() if err == nil && pid > 0 { - fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("pv is already running %s", ui.Muted.Render(fmt.Sprintf("(PID %d)", pid)))) - fmt.Fprintln(os.Stderr) return nil } } @@ -60,14 +54,10 @@ func startDaemon() error { // Also check foreground PID file. if server.IsRunning() { pid, _ := server.ReadPID() - fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("pv is already running in foreground %s", ui.Muted.Render(fmt.Sprintf("(PID %d)", pid)))) - fmt.Fprintln(os.Stderr) return nil } - fmt.Fprintln(os.Stderr) - if err := ui.Step("Starting pv daemon...", func() (string, error) { // Generate and write plist. cfg := daemon.DefaultPlistConfig() @@ -100,7 +90,6 @@ func startDaemon() error { } ui.Subtle("Run pv log to view logs") - fmt.Fprintln(os.Stderr) return nil } diff --git a/cmd/status.go b/cmd/status.go index 8fd5281..c476aac 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -15,8 +15,9 @@ import ( ) var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show pv server status", + Use: "status", + GroupID: "core", + Short: "Show pv server status", RunE: func(cmd *cobra.Command, args []string) error { settings, err := config.LoadSettings() if err != nil { diff --git a/cmd/stop.go b/cmd/stop.go index 943c290..09797fb 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -13,11 +13,10 @@ import ( ) var stopCmd = &cobra.Command{ - Use: "stop", - Short: "Stop the pv server", + Use: "stop", + GroupID: "server", + Short: "Stop the pv server", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr) - // Check daemon mode first. if daemon.IsLoaded() { if err := ui.Step("Stopping pv daemon...", func() (string, error) { @@ -37,8 +36,6 @@ var stopCmd = &cobra.Command{ }); err != nil { return err } - - fmt.Fprintln(os.Stderr) return nil } @@ -46,7 +43,6 @@ var stopCmd = &cobra.Command{ pid, err := server.ReadPID() if err != nil { ui.Subtle("pv is not running") - fmt.Fprintln(os.Stderr) return nil } @@ -73,7 +69,6 @@ var stopCmd = &cobra.Command{ return err } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 399450b..c0af908 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -1,16 +1,20 @@ package cmd import ( - "bufio" "encoding/json" + "errors" "fmt" "os" "os/exec" "path/filepath" - "strings" "syscall" "time" + "charm.land/huh/v2" + colimacmd "github.com/prvious/pv/internal/commands/colima" + "github.com/prvious/pv/internal/commands/composer" + "github.com/prvious/pv/internal/commands/mago" + "github.com/prvious/pv/internal/commands/php" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/registry" @@ -20,49 +24,60 @@ import ( "github.com/spf13/cobra" ) +var forceUninstall bool + var uninstallCmd = &cobra.Command{ - Use: "uninstall", - Short: "Completely remove pv and all its data", + Use: "uninstall", + GroupID: "core", + Short: "Completely remove pv and all its data", RunE: func(cmd *cobra.Command, args []string) error { // 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: ") + ui.Subtle("This will remove:") + ui.Subtle("- The pv binary") + ui.Subtle("- All PHP versions and FrankenPHP binaries") + ui.Subtle("- All Composer global packages and cache") + ui.Subtle("- All project links (your project files are NOT deleted)") + ui.Subtle("- DNS resolver configuration") + ui.Subtle("- Trusted CA certificate") + ui.Subtle("- Launchd service") + ui.Subtle("") + ui.Subtle("Your projects themselves will not be touched.") - scanner := bufio.NewScanner(os.Stdin) - scanner.Scan() - if strings.TrimSpace(scanner.Text()) != "uninstall" { - fmt.Fprintln(os.Stderr, "Aborted.") - return nil + if !forceUninstall { + var confirmation string + if err := huh.NewInput(). + Title("Type \"uninstall\" to confirm"). + Value(&confirmation). + Run(); err != nil { + return err + } + if confirmation != "uninstall" { + ui.Subtle("Aborted.") + return nil + } } - fmt.Fprintln(os.Stderr) // Auth backup offer. authPath := filepath.Join(config.ComposerDir(), "auth.json") - if hasAuthTokens(authPath) { - 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" { + if !forceUninstall && hasAuthTokens(authPath) { + backupAuth := true + if err := huh.NewConfirm(). + Title("Back up Composer auth tokens to ~/pv-auth-backup.json?"). + Affirmative("Yes"). + Negative("No"). + Value(&backupAuth). + Run(); err != nil { + return err + } + if backupAuth { home, _ := os.UserHomeDir() backupPath := filepath.Join(home, "pv-auth-backup.json") if err := copyFile(authPath, backupPath); err != nil { - fmt.Fprintf(os.Stderr, " Warning: could not back up auth tokens: %v\n", err) + ui.Fail(fmt.Sprintf("Could not back up auth tokens: %v", err)) } else { ui.Success(fmt.Sprintf("Backed up to %s", backupPath)) } } - fmt.Fprintln(os.Stderr) } // Read registry before deletion (for .pv-php file scan later). @@ -75,20 +90,31 @@ var uninstallCmd = &cobra.Command{ } settings, _ := config.LoadSettings() - tld := settings.TLD + tld := "test" + if settings != nil { + tld = settings.TLD + } // 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 err := colimacmd.RunUninstall(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Colima uninstall failed: %v", err)) + } } - if err := phpUninstallCmd.RunE(phpUninstallCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s PHP uninstall failed: %v\n", ui.Red.Render("!"), err) + if err := php.RunUninstall(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("PHP uninstall failed: %v", err)) + } } - if err := magoUninstallCmd.RunE(magoUninstallCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Mago uninstall failed: %v\n", ui.Red.Render("!"), err) + if err := mago.RunUninstall(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Mago uninstall failed: %v", err)) + } } - if err := composerUninstallCmd.RunE(composerUninstallCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Composer uninstall failed: %v\n", ui.Red.Render("!"), err) + if err := composer.RunUninstall(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Composer uninstall failed: %v", err)) + } } // Stop services. @@ -122,7 +148,7 @@ var uninstallCmd = &cobra.Command{ return "Services stopped", nil }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } // Remove launchd plist. @@ -132,17 +158,18 @@ var uninstallCmd = &cobra.Command{ } return "Launchd service removed", nil }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } // Remove system configuration (sudo). if err := ui.Step("Removing DNS resolver...", func() (string, error) { - if runSudo(fmt.Sprintf("rm -f /etc/resolver/%s", tld)) { + resolverFile := filepath.Join("/etc/resolver", tld) + if runSudo("rm", "-f", resolverFile) { return "DNS resolver removed", nil } - return "", fmt.Errorf("could not remove /etc/resolver/%s — run: sudo rm -f /etc/resolver/%s", tld, tld) + return "", fmt.Errorf("could not remove %s — run: sudo rm -f %s", resolverFile, resolverFile) }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } // Untrust CA certificate. @@ -171,7 +198,7 @@ var uninstallCmd = &cobra.Command{ return "", fmt.Errorf("CA removal timed out — run: sudo security remove-trusted-cert -d %s", caCertPath) } }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } } @@ -179,14 +206,14 @@ var uninstallCmd = &cobra.Command{ 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)) { + if runSudo("rm", "-rf", pvDir) { return "~/.pv removed", nil } return "", fmt.Errorf("could not fully remove %s", pvDir) } return "~/.pv removed", nil }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } // Remove the pv binary itself. @@ -199,18 +226,17 @@ var uninstallCmd = &cobra.Command{ pvBin = resolved } if err := os.Remove(pvBin); err != nil { - if runSudo(fmt.Sprintf("rm -f '%s'", pvBin)) { + if runSudo("rm", "-f", pvBin) { return fmt.Sprintf("Removed %s", pvBin), nil } 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) + // Error already displayed by ui.Step } // Report scattered .pv-php files. - fmt.Fprintln(os.Stderr) var found []string for _, p := range projectPaths { pvPhpPath := filepath.Join(p, ".pv-php") @@ -219,12 +245,11 @@ var uninstallCmd = &cobra.Command{ } } if len(found) > 0 { - fmt.Fprintln(os.Stderr, "Found .pv-php files in your projects:") + ui.Subtle("Found .pv-php files in your projects:") for _, f := range found { - fmt.Fprintf(os.Stderr, " %s\n", f) + ui.Subtle(fmt.Sprintf(" %s", f)) } - fmt.Fprintln(os.Stderr, "You can safely delete these.") - fmt.Fprintln(os.Stderr) + ui.Subtle("You can safely delete these.") } // Print manual steps. @@ -232,13 +257,12 @@ var uninstallCmd = &cobra.Command{ configFile := setup.ShellConfigFile(shell) exportLine := setup.PathExportLine(shell) - 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.") + ui.Subtle("Remove the pv lines from your shell config:") + ui.Subtle(fmt.Sprintf(" # Remove from %s:", configFile)) + ui.Subtle(fmt.Sprintf(" %s", exportLine)) + ui.Subtle(" eval \"$(pv env)\" # if present") + + ui.Success("pv has been completely uninstalled. Your projects were not modified.") return nil }, @@ -263,17 +287,19 @@ func copyFile(src, dst string) error { if err != nil { return err } - return os.WriteFile(dst, data, 0644) + return os.WriteFile(dst, data, 0600) } // runSudo runs a command via sudo -n (non-interactive). Returns true on success. -func runSudo(script string) bool { - cmd := exec.Command("sudo", "-n", "sh", "-c", script) +func runSudo(args ...string) bool { + cmdArgs := append([]string{"-n"}, args...) + cmd := exec.Command("sudo", cmdArgs...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() == nil } func init() { + uninstallCmd.Flags().BoolVarP(&forceUninstall, "force", "f", false, "Skip confirmation prompt") rootCmd.AddCommand(uninstallCmd) } diff --git a/cmd/unlink.go b/cmd/unlink.go index 67952ae..1befd3b 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -14,9 +14,15 @@ import ( ) var unlinkCmd = &cobra.Command{ - Use: "unlink [name]", - Short: "Unlink a project", - Args: cobra.MaximumNArgs(1), + Use: "unlink [name]", + GroupID: "core", + Short: "Unlink a project", + Example: `# Unlink by name +pv unlink myapp + +# Unlink the current directory +pv unlink`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { @@ -34,22 +40,14 @@ var unlinkCmd = &cobra.Command{ absPath, _ := filepath.Abs(cwd) p := reg.FindByPath(absPath) if p == nil { - fmt.Fprintln(os.Stderr) - ui.Fail("Current directory is not a linked project") - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("current directory is not a linked project") } name = p.Name } // Check project exists before removing. if reg.Find(name) == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Project %s is not linked", ui.Bold.Render(name))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("project %q is not linked", name) } if err := reg.Remove(name); err != nil { @@ -79,14 +77,10 @@ var unlinkCmd = &cobra.Command{ if server.IsRunning() { if err := server.ReconfigureServer(); err != nil { - fmt.Fprintf(os.Stderr, " %s %s\n", - ui.Red.Render("!"), - ui.Muted.Render(fmt.Sprintf("Could not reconfigure server: %v", err)), - ) + ui.Fail(fmt.Sprintf("Could not reconfigure server: %v", err)) } } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/update.go b/cmd/update.go index 327d6a6..04bdfed 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "net/http" "os" @@ -9,19 +10,24 @@ import ( "time" "github.com/prvious/pv/internal/colima" + colimacmd "github.com/prvious/pv/internal/commands/colima" + "github.com/prvious/pv/internal/commands/composer" + "github.com/prvious/pv/internal/commands/mago" + "github.com/prvious/pv/internal/commands/php" "github.com/prvious/pv/internal/selfupdate" "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) var ( - updateVerbose bool - noSelfUpdate bool + updateVerbose bool + noSelfUpdate bool ) var updateCmd = &cobra.Command{ - Use: "update", - Short: "Update pv and all managed tools to their latest versions", + Use: "update", + GroupID: "core", + Short: "Update pv and all managed tools to their latest versions", RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() @@ -33,7 +39,9 @@ var updateCmd = &cobra.Command{ 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 !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("pv self-update failed: %v", err)) + } } if reexeced { return nil // reached only if syscall.Exec failed (error already printed) @@ -43,24 +51,32 @@ var updateCmd = &cobra.Command{ // Step 2: Update tools. var failures []string - if err := phpUpdateCmd.RunE(phpUpdateCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s PHP update failed: %v\n", ui.Red.Render("!"), err) + if err := php.RunUpdate(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("PHP update failed: %v", err)) + } failures = append(failures, "PHP") } - if err := magoUpdateCmd.RunE(magoUpdateCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Mago update failed: %v\n", ui.Red.Render("!"), err) + if err := mago.RunUpdate(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Mago update failed: %v", err)) + } failures = append(failures, "Mago") } - if err := composerUpdateCmd.RunE(composerUpdateCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Composer update failed: %v\n", ui.Red.Render("!"), err) + if err := composer.RunUpdate(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Composer update failed: %v", err)) + } failures = append(failures, "Composer") } if colima.IsInstalled() { - if err := colimaUpdateCmd.RunE(colimaUpdateCmd, nil); err != nil { - fmt.Fprintf(os.Stderr, " %s Colima update failed: %v\n", ui.Red.Render("!"), err) + if err := colimacmd.RunUpdate(); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Colima update failed: %v", err)) + } failures = append(failures, "Colima") } } diff --git a/docs/features/daemon.md b/docs/features/daemon.md index 2b76e6d..e94e5b0 100644 --- a/docs/features/daemon.md +++ b/docs/features/daemon.md @@ -141,7 +141,7 @@ Pull PID and uptime from launchctl. Pull project info from registry. If not runn The plist needs to be regenerated when certain things change: -- `pv use php:` → main binary path changes +- `pv use php:[version]` → main binary path changes - pv binary itself gets updated - Environment variables change @@ -154,13 +154,13 @@ Add to `internal/daemon/` — runs on any OS, no launchd needed. **`internal/daemon/plist_test.go`**: - **Plist XML correctness** — render template with a `PlistConfig`, assert the XML contains: - - Correct `Label` (`dev.prvious.pv`) - - `ProgramArguments` array with the binary path + `start` + `--foreground` - - `KeepAlive` set to `true` - - `RunAtLoad` set to `false` (default) and `true` (when auto-start enabled) - - `StandardOutPath` / `StandardErrorPath` pointing to `~/.pv/logs/` - - `EnvironmentVariables` containing `PATH` and `XDG_DATA_HOME` - - `WorkingDirectory` set to `~/.pv` + - Correct `Label` (`dev.prvious.pv`) + - `ProgramArguments` array with the binary path + `start` + `--foreground` + - `KeepAlive` set to `true` + - `RunAtLoad` set to `false` (default) and `true` (when auto-start enabled) + - `StandardOutPath` / `StandardErrorPath` pointing to `~/.pv/logs/` + - `EnvironmentVariables` containing `PATH` and `XDG_DATA_HOME` + - `WorkingDirectory` set to `~/.pv` - **Dynamic paths** — assert rendered paths use the actual `HOME` dir, not hardcoded values - **Env vars** — pass custom env vars in `PlistConfig.EnvVars`, assert they appear in output @@ -337,26 +337,26 @@ Update the CI cleanup step: - name: Cleanup if: always() run: | - launchctl unload ~/Library/LaunchAgents/dev.prvious.pv.plist 2>/dev/null || true - rm -f ~/Library/LaunchAgents/dev.prvious.pv.plist - sudo -E pv stop 2>/dev/null || true + launchctl unload ~/Library/LaunchAgents/dev.prvious.pv.plist 2>/dev/null || true + rm -f ~/Library/LaunchAgents/dev.prvious.pv.plist + sudo -E pv stop 2>/dev/null || true ``` --- ### Test Coverage Summary -| What | Where | Script / File | -|---|---|---| -| Plist XML correctness | Go unit test | `internal/daemon/plist_test.go` | -| Plist sync/diff detection | Go unit test | `internal/daemon/sync_test.go` | -| Daemon start + stop (launchd lifecycle) | E2E bash | `scripts/e2e/daemon-start-stop.sh` | -| Crash recovery (KeepAlive) | E2E bash | `scripts/e2e/daemon-crash-recovery.sh` | -| Full stack (link → daemon → curl) | E2E bash | `scripts/e2e/daemon-full-stack.sh` | -| DNS + HTTP serving | E2E bash | Covered by existing `start-curl.sh` | -| Restart behavior | E2E bash | Covered by existing `restart.sh` | -| Log output | E2E bash | Covered by existing `log.sh` | -| Auto-start on login (RunAtLoad) | Manual only | Not testable in CI | +| What | Where | Script / File | +| --------------------------------------- | ------------ | -------------------------------------- | +| Plist XML correctness | Go unit test | `internal/daemon/plist_test.go` | +| Plist sync/diff detection | Go unit test | `internal/daemon/sync_test.go` | +| Daemon start + stop (launchd lifecycle) | E2E bash | `scripts/e2e/daemon-start-stop.sh` | +| Crash recovery (KeepAlive) | E2E bash | `scripts/e2e/daemon-crash-recovery.sh` | +| Full stack (link → daemon → curl) | E2E bash | `scripts/e2e/daemon-full-stack.sh` | +| DNS + HTTP serving | E2E bash | Covered by existing `start-curl.sh` | +| Restart behavior | E2E bash | Covered by existing `restart.sh` | +| Log output | E2E bash | Covered by existing `log.sh` | +| Auto-start on login (RunAtLoad) | Manual only | Not testable in CI | --- diff --git a/docs/features/services.md b/docs/features/services.md index 005d831..6fc0b2d 100644 --- a/docs/features/services.md +++ b/docs/features/services.md @@ -191,9 +191,9 @@ type MySQLService struct { } ``` -- Image: `mysql:` +- Image: `mysql:[version]` - Environment: `MYSQL_ALLOW_EMPTY_PASSWORD=yes` -- Volume: `~/.pv/services/mysql//data:/var/lib/mysql` +- Volume: `~/.pv/services/mysql/[version]/data:/var/lib/mysql` - Port: `:3306` - Health check: `mysqladmin ping -h 127.0.0.1` - Database creation: `CREATE DATABASE IF NOT EXISTS ` @@ -208,9 +208,9 @@ type PostgresService struct { } ``` -- Image: `postgres:` +- Image: `postgres:[version]` - Environment: `POSTGRES_HOST_AUTH_METHOD=trust` -- Volume: `~/.pv/services/postgres//data:/var/lib/postgresql/data` +- Volume: `~/.pv/services/postgres/[version]/data:/var/lib/postgresql/data` - Port: `:5432` - Health check: `pg_isready` - Database creation: `CREATE DATABASE ` @@ -225,8 +225,8 @@ type RedisService struct { } ``` -- Image: `redis:` -- Volume: `~/.pv/services/redis//data:/data` +- Image: `redis:[version]` +- Volume: `~/.pv/services/redis/[version]/data:/data` - Port: `6379:6379` - Health check: `redis-cli ping` - No credentials, no per-project databases needed @@ -262,7 +262,7 @@ Flow: 3. Check if this exact service+version already exists in registry → if so, print "already added" and exit 4. Ensure Colima is running (start if not) 5. Pull the Docker image (with spinner/progress) -6. Create data directory at `~/.pv/services///data/` +6. Create data directory at `~/.pv/services//[version]/data/` 7. Create and start the container with appropriate config from Task 3 8. Wait for health check to pass 9. Update registry with container ID, port, status diff --git a/go.mod b/go.mod index 553bbf7..d93edd2 100644 --- a/go.mod +++ b/go.mod @@ -3,41 +3,46 @@ module github.com/prvious/pv go 1.25.0 require ( - github.com/charmbracelet/huh v0.8.0 - github.com/charmbracelet/lipgloss v1.1.0 + charm.land/fang/v2 v2.0.0-20260307033620-73847d32708b + charm.land/huh/v2 v2.0.0-20260226141913-a8934362ea3b + charm.land/lipgloss/v2 v2.0.0 + github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 github.com/miekg/dns v1.1.72 github.com/spf13/cobra v1.10.2 ) require ( + charm.land/bubbles/v2 v2.0.0 // indirect + charm.land/bubbletea/v2 v2.0.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect - github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.6 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/x/ansi v0.9.3 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect - github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/termenv v0.16.0 // indirect + github.com/muesli/mango v0.1.0 // indirect + github.com/muesli/mango-cobra v1.2.0 // indirect + github.com/muesli/mango-pflag v0.1.0 // indirect + github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.39.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.32.0 // indirect golang.org/x/tools v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index f337662..2b7e5e5 100644 --- a/go.sum +++ b/go.sum @@ -1,71 +1,82 @@ +charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s= +charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI= +charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ= +charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= +charm.land/fang/v2 v2.0.0-20260307033620-73847d32708b h1:cVzracFCS7XPLuCA868mcoppOxlwdYZ2DF6hgHDk1Fc= +charm.land/fang/v2 v2.0.0-20260307033620-73847d32708b/go.mod h1:InCmamoYgLlZHBQqlM2rQ7ec7nLHaHVi3OmWSCu5YNw= +charm.land/huh/v2 v2.0.0-20260226141913-a8934362ea3b h1:ND5O+7b1ECsouKP7Wmj02u5oGxouuevVyicCo1n0FCY= +charm.land/huh/v2 v2.0.0-20260226141913-a8934362ea3b/go.mod h1:0WOQ7ZIycEMUsvhcmBMda7tAGkEy9Tvvs6OreNllufA= +charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo= +charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= -github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= -github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= -github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= -github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM= +github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= -github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= -github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= -github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= -github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= -github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= -github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= -github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= -github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= -github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= -github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= +github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= +github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= -github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185 h1:/192monmpmRICpSPrFRzkIO+xfhioV6/nwrQdkDTj10= +github.com/charmbracelet/x/exp/charmtone v0.0.0-20260305213658-fe36e8c10185/go.mod h1:nsExn0DGyX0lh9LwLHTn2Gg+hafdzfSXnC+QmEJTZFY= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE= +github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= -github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= -github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw= +github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= -github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= -github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= -github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= -github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= -github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/muesli/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI= +github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4= +github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg= +github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA= +github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg= +github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0= +github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8= +github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -73,6 +84,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= @@ -84,12 +97,12 @@ golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.sh b/install.sh index 3fad7ef..ca99aca 100755 --- a/install.sh +++ b/install.sh @@ -20,9 +20,9 @@ Usage: install.sh [options] Options: -h, --help Display this help message - -v, --version Install a specific pv version (e.g., 0.1.0) + -v, --version [version] Install a specific pv version (e.g., 0.1.0) --install-dir Where to install the pv binary (default: ~/.local/bin) - --php PHP version to install (e.g., 8.4). Auto-detects if omitted. + --php [version] PHP version to install (e.g., 8.4). Auto-detects if omitted. --tld Top-level domain for local sites (default: test) --no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.) diff --git a/cmd/colima_download.go b/internal/commands/colima/download.go similarity index 58% rename from cmd/colima_download.go rename to internal/commands/colima/download.go index 2d0a79d..29a6956 100644 --- a/cmd/colima_download.go +++ b/internal/commands/colima/download.go @@ -1,29 +1,26 @@ -package cmd +package colima import ( "fmt" "net/http" - "github.com/prvious/pv/internal/colima" + internalcolima "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", +var downloadCmd = &cobra.Command{ + Use: "colima:download", + GroupID: "colima", + 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 { + if err := internalcolima.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/internal/commands/colima/install.go similarity index 55% rename from cmd/colima_install.go rename to internal/commands/colima/install.go index 4a693c4..a5d3b5e 100644 --- a/cmd/colima_install.go +++ b/internal/commands/colima/install.go @@ -1,21 +1,19 @@ -package cmd +package colima import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) -var colimaInstallCmd = &cobra.Command{ - Use: "colima:install", - Short: "Install or update the Colima container runtime", +var installCmd = &cobra.Command{ + Use: "colima:install", + GroupID: "colima", + Short: "Install or update the Colima container runtime", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr) - // Download. - if err := colimaDownloadCmd.RunE(colimaDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -27,11 +25,6 @@ var colimaInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(colimaInstallCmd) -} diff --git a/cmd/colima_path.go b/internal/commands/colima/path.go similarity index 62% rename from cmd/colima_path.go rename to internal/commands/colima/path.go index 2972b8d..469615b 100644 --- a/cmd/colima_path.go +++ b/internal/commands/colima/path.go @@ -1,4 +1,4 @@ -package cmd +package colima import ( "fmt" @@ -8,15 +8,16 @@ import ( "github.com/spf13/cobra" ) -var colimaPathRemove bool +var pathRemove bool -var colimaPathCmd = &cobra.Command{ - Use: "colima:path", - Short: "Expose or remove Colima from PATH", +var pathCmd = &cobra.Command{ + Use: "colima:path", + GroupID: "colima", + Short: "Expose or remove Colima from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("colima") - if colimaPathRemove { + if pathRemove { if err := tools.Unexpose(t); err != nil { return err } @@ -33,6 +34,5 @@ var colimaPathCmd = &cobra.Command{ } func init() { - colimaPathCmd.Flags().BoolVar(&colimaPathRemove, "remove", false, "Remove from PATH instead of adding") - rootCmd.AddCommand(colimaPathCmd) + pathCmd.Flags().BoolVar(&pathRemove, "remove", false, "Remove from PATH instead of adding") } diff --git a/internal/commands/colima/register.go b/internal/commands/colima/register.go new file mode 100644 index 0000000..0e750d9 --- /dev/null +++ b/internal/commands/colima/register.go @@ -0,0 +1,23 @@ +package colima + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(installCmd) + parent.AddCommand(downloadCmd) + parent.AddCommand(pathCmd) + parent.AddCommand(updateCmd) + parent.AddCommand(uninstallCmd) +} + +func RunInstall() error { + return installCmd.RunE(installCmd, nil) +} + +func RunUpdate() error { + return updateCmd.RunE(updateCmd, nil) +} + +func RunUninstall() error { + return uninstallCmd.RunE(uninstallCmd, nil) +} diff --git a/internal/commands/colima/register_test.go b/internal/commands/colima/register_test.go new file mode 100644 index 0000000..07c7e9b --- /dev/null +++ b/internal/commands/colima/register_test.go @@ -0,0 +1,21 @@ +package colima + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "colima", Title: "Colima"}) + Register(root) + + expected := []string{"colima:install", "colima:download", "colima:path", "colima:update", "colima:uninstall"} + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/cmd/colima_uninstall.go b/internal/commands/colima/uninstall.go similarity index 68% rename from cmd/colima_uninstall.go rename to internal/commands/colima/uninstall.go index 4d23fc1..9bd44b9 100644 --- a/cmd/colima_uninstall.go +++ b/internal/commands/colima/uninstall.go @@ -1,31 +1,32 @@ -package cmd +package colima import ( "fmt" "os" - "github.com/prvious/pv/internal/colima" + internalcolima "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", +var uninstallCmd = &cobra.Command{ + Use: "colima:uninstall", + GroupID: "colima", + Short: "Stop Colima VM and remove the binary", RunE: func(cmd *cobra.Command, args []string) error { - if !colima.IsInstalled() { + if !internalcolima.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 { + if internalcolima.IsRunning() { + if err := internalcolima.Stop(); err != nil { return "", fmt.Errorf("cannot stop Colima VM (stop it manually before uninstalling): %w", err) } - if err := colima.Delete(); err != nil { + if err := internalcolima.Delete(); err != nil { return "", fmt.Errorf("cannot delete Colima VM: %w", err) } } @@ -42,7 +43,3 @@ var colimaUninstallCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(colimaUninstallCmd) -} diff --git a/cmd/colima_update.go b/internal/commands/colima/update.go similarity index 62% rename from cmd/colima_update.go rename to internal/commands/colima/update.go index f23bbdc..64e0424 100644 --- a/cmd/colima_update.go +++ b/internal/commands/colima/update.go @@ -1,25 +1,26 @@ -package cmd +package colima import ( "fmt" - "github.com/prvious/pv/internal/colima" + internalcolima "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", +var updateCmd = &cobra.Command{ + Use: "colima:update", + GroupID: "colima", + Short: "Update Colima to the latest version", RunE: func(cmd *cobra.Command, args []string) error { - if !colima.IsInstalled() { + if !internalcolima.IsInstalled() { ui.Success("Colima not installed (run: pv colima:install)") return nil } // Delegate download to :download. - if err := colimaDownloadCmd.RunE(colimaDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -34,7 +35,3 @@ var colimaUpdateCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(colimaUpdateCmd) -} diff --git a/cmd/composer_download.go b/internal/commands/composer/download.go similarity index 85% rename from cmd/composer_download.go rename to internal/commands/composer/download.go index 54b8384..2b72675 100644 --- a/cmd/composer_download.go +++ b/internal/commands/composer/download.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var composerDownloadCmd = &cobra.Command{ - Use: "composer:download", - Short: "Download Composer to internal storage", +var downloadCmd = &cobra.Command{ + Use: "composer:download", + GroupID: "composer", + Short: "Download Composer to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} @@ -44,7 +45,3 @@ var composerDownloadCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(composerDownloadCmd) -} diff --git a/cmd/composer_install.go b/internal/commands/composer/install.go similarity index 54% rename from cmd/composer_install.go rename to internal/commands/composer/install.go index 598932a..28baa02 100644 --- a/cmd/composer_install.go +++ b/internal/commands/composer/install.go @@ -1,21 +1,19 @@ -package cmd +package composer import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) -var composerInstallCmd = &cobra.Command{ - Use: "composer:install", - Short: "Install or update Composer", +var installCmd = &cobra.Command{ + Use: "composer:install", + GroupID: "composer", + Short: "Install or update Composer", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr) - // Download. - if err := composerDownloadCmd.RunE(composerDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -27,11 +25,6 @@ var composerInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(composerInstallCmd) -} diff --git a/cmd/composer_path.go b/internal/commands/composer/path.go similarity index 61% rename from cmd/composer_path.go rename to internal/commands/composer/path.go index 64b7441..2995aaa 100644 --- a/cmd/composer_path.go +++ b/internal/commands/composer/path.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -8,15 +8,16 @@ import ( "github.com/spf13/cobra" ) -var composerPathRemove bool +var pathRemove bool -var composerPathCmd = &cobra.Command{ - Use: "composer:path", - Short: "Expose or remove Composer from PATH", +var pathCmd = &cobra.Command{ + Use: "composer:path", + GroupID: "composer", + Short: "Expose or remove Composer from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("composer") - if composerPathRemove { + if pathRemove { if err := tools.Unexpose(t); err != nil { return err } @@ -33,6 +34,5 @@ var composerPathCmd = &cobra.Command{ } func init() { - composerPathCmd.Flags().BoolVar(&composerPathRemove, "remove", false, "Remove from PATH instead of adding") - rootCmd.AddCommand(composerPathCmd) + pathCmd.Flags().BoolVar(&pathRemove, "remove", false, "Remove from PATH instead of adding") } diff --git a/internal/commands/composer/register.go b/internal/commands/composer/register.go new file mode 100644 index 0000000..35fd438 --- /dev/null +++ b/internal/commands/composer/register.go @@ -0,0 +1,23 @@ +package composer + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(installCmd) + parent.AddCommand(downloadCmd) + parent.AddCommand(pathCmd) + parent.AddCommand(updateCmd) + parent.AddCommand(uninstallCmd) +} + +func RunInstall() error { + return installCmd.RunE(installCmd, nil) +} + +func RunUpdate() error { + return updateCmd.RunE(updateCmd, nil) +} + +func RunUninstall() error { + return uninstallCmd.RunE(uninstallCmd, nil) +} diff --git a/internal/commands/composer/register_test.go b/internal/commands/composer/register_test.go new file mode 100644 index 0000000..21af567 --- /dev/null +++ b/internal/commands/composer/register_test.go @@ -0,0 +1,21 @@ +package composer + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "composer", Title: "Composer"}) + Register(root) + + expected := []string{"composer:install", "composer:download", "composer:path", "composer:update", "composer:uninstall"} + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/cmd/composer_uninstall.go b/internal/commands/composer/uninstall.go similarity index 76% rename from cmd/composer_uninstall.go rename to internal/commands/composer/uninstall.go index d0e5004..3f3306e 100644 --- a/cmd/composer_uninstall.go +++ b/internal/commands/composer/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var composerUninstallCmd = &cobra.Command{ - Use: "composer:uninstall", - Short: "Remove Composer PHAR, PATH entry, and global packages", +var uninstallCmd = &cobra.Command{ + Use: "composer:uninstall", + GroupID: "composer", + 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 { @@ -31,7 +32,3 @@ var composerUninstallCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(composerUninstallCmd) -} diff --git a/cmd/composer_update.go b/internal/commands/composer/update.go similarity index 63% rename from cmd/composer_update.go rename to internal/commands/composer/update.go index f500316..ee1c40d 100644 --- a/cmd/composer_update.go +++ b/internal/commands/composer/update.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -7,12 +7,13 @@ import ( "github.com/spf13/cobra" ) -var composerUpdateCmd = &cobra.Command{ - Use: "composer:update", - Short: "Update Composer to the latest version", +var updateCmd = &cobra.Command{ + Use: "composer:update", + GroupID: "composer", + 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 { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -27,7 +28,3 @@ var composerUpdateCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(composerUpdateCmd) -} diff --git a/internal/commands/daemon/disable.go b/internal/commands/daemon/disable.go new file mode 100644 index 0000000..6066035 --- /dev/null +++ b/internal/commands/daemon/disable.go @@ -0,0 +1,31 @@ +package daemon + +import ( + "fmt" + + internaldaemon "github.com/prvious/pv/internal/daemon" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var disableCmd = &cobra.Command{ + Use: "daemon:disable", + GroupID: "daemon", + Short: "Disable the pv login daemon", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.Step("Uninstalling pv daemon...", func() (string, error) { + // Unload if loaded. + if internaldaemon.IsLoaded() { + if err := internaldaemon.Unload(); err != nil { + return "", fmt.Errorf("cannot stop daemon: %w", err) + } + } + + if err := internaldaemon.Uninstall(); err != nil { + return "", fmt.Errorf("cannot uninstall daemon: %w", err) + } + + return "Daemon uninstalled", nil + }) + }, +} diff --git a/internal/commands/daemon/enable.go b/internal/commands/daemon/enable.go new file mode 100644 index 0000000..4a6e177 --- /dev/null +++ b/internal/commands/daemon/enable.go @@ -0,0 +1,32 @@ +package daemon + +import ( + "fmt" + + internaldaemon "github.com/prvious/pv/internal/daemon" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var enableCmd = &cobra.Command{ + Use: "daemon:enable", + GroupID: "daemon", + Short: "Enable pv as a login daemon (starts on boot)", + RunE: func(cmd *cobra.Command, args []string) error { + return ui.Step("Installing pv daemon...", func() (string, error) { + cfg := internaldaemon.DefaultPlistConfig() + cfg.RunAtLoad = true + + if err := internaldaemon.Install(cfg); err != nil { + return "", fmt.Errorf("cannot install daemon: %w", err) + } + + // Load the daemon so it starts immediately. + if err := internaldaemon.Load(); err != nil { + return "", fmt.Errorf("cannot start daemon: %w", err) + } + + return "Daemon installed (starts automatically on login)", nil + }) + }, +} diff --git a/internal/commands/daemon/register.go b/internal/commands/daemon/register.go new file mode 100644 index 0000000..eda25f3 --- /dev/null +++ b/internal/commands/daemon/register.go @@ -0,0 +1,13 @@ +package daemon + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(enableCmd) + parent.AddCommand(disableCmd) + parent.AddCommand(restartCmd) +} + +func RunRestart() error { + return restartCmd.RunE(restartCmd, nil) +} diff --git a/internal/commands/daemon/register_test.go b/internal/commands/daemon/register_test.go new file mode 100644 index 0000000..4d83cee --- /dev/null +++ b/internal/commands/daemon/register_test.go @@ -0,0 +1,21 @@ +package daemon + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "daemon", Title: "Daemon"}) + Register(root) + + expected := []string{"daemon:enable", "daemon:disable", "daemon:restart"} + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/internal/commands/daemon/restart.go b/internal/commands/daemon/restart.go new file mode 100644 index 0000000..a5b48d0 --- /dev/null +++ b/internal/commands/daemon/restart.go @@ -0,0 +1,27 @@ +package daemon + +import ( + "fmt" + + internaldaemon "github.com/prvious/pv/internal/daemon" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var restartCmd = &cobra.Command{ + Use: "daemon:restart", + GroupID: "daemon", + Short: "Restart the pv daemon", + RunE: func(cmd *cobra.Command, args []string) error { + if !internaldaemon.IsLoaded() { + return fmt.Errorf("daemon is not running") + } + + return ui.Step("Restarting pv daemon...", func() (string, error) { + if err := internaldaemon.Restart(); err != nil { + return "", fmt.Errorf("cannot restart daemon: %w", err) + } + return "Daemon restarted", nil + }) + }, +} diff --git a/cmd/mago_download.go b/internal/commands/mago/download.go similarity index 86% rename from cmd/mago_download.go rename to internal/commands/mago/download.go index f7fe9e8..f7f3680 100644 --- a/cmd/mago_download.go +++ b/internal/commands/mago/download.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var magoDownloadCmd = &cobra.Command{ - Use: "mago:download", - Short: "Download Mago to internal storage", +var downloadCmd = &cobra.Command{ + Use: "mago:download", + GroupID: "mago", + Short: "Download Mago to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} @@ -44,7 +45,3 @@ var magoDownloadCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(magoDownloadCmd) -} diff --git a/cmd/mago_install.go b/internal/commands/mago/install.go similarity index 56% rename from cmd/mago_install.go rename to internal/commands/mago/install.go index bb88dd9..1e12557 100644 --- a/cmd/mago_install.go +++ b/internal/commands/mago/install.go @@ -1,21 +1,19 @@ -package cmd +package mago import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" ) -var magoInstallCmd = &cobra.Command{ - Use: "mago:install", - Short: "Install or update Mago", +var installCmd = &cobra.Command{ + Use: "mago:install", + GroupID: "mago", + Short: "Install or update Mago", RunE: func(cmd *cobra.Command, args []string) error { - fmt.Fprintln(os.Stderr) - // Download. - if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -27,11 +25,6 @@ var magoInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(magoInstallCmd) -} diff --git a/cmd/mago_path.go b/internal/commands/mago/path.go similarity index 63% rename from cmd/mago_path.go rename to internal/commands/mago/path.go index 0eca6ca..0e11161 100644 --- a/cmd/mago_path.go +++ b/internal/commands/mago/path.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -8,15 +8,16 @@ import ( "github.com/spf13/cobra" ) -var magoPathRemove bool +var pathRemove bool -var magoPathCmd = &cobra.Command{ - Use: "mago:path", - Short: "Expose or remove Mago from PATH", +var pathCmd = &cobra.Command{ + Use: "mago:path", + GroupID: "mago", + Short: "Expose or remove Mago from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("mago") - if magoPathRemove { + if pathRemove { if err := tools.Unexpose(t); err != nil { return err } @@ -33,6 +34,5 @@ var magoPathCmd = &cobra.Command{ } func init() { - magoPathCmd.Flags().BoolVar(&magoPathRemove, "remove", false, "Remove from PATH instead of adding") - rootCmd.AddCommand(magoPathCmd) + pathCmd.Flags().BoolVar(&pathRemove, "remove", false, "Remove from PATH instead of adding") } diff --git a/internal/commands/mago/register.go b/internal/commands/mago/register.go new file mode 100644 index 0000000..8e39eeb --- /dev/null +++ b/internal/commands/mago/register.go @@ -0,0 +1,27 @@ +package mago + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(installCmd) + parent.AddCommand(downloadCmd) + parent.AddCommand(pathCmd) + parent.AddCommand(updateCmd) + parent.AddCommand(uninstallCmd) +} + +func RunInstall() error { + return installCmd.RunE(installCmd, nil) +} + +func RunDownload() error { + return downloadCmd.RunE(downloadCmd, nil) +} + +func RunUpdate() error { + return updateCmd.RunE(updateCmd, nil) +} + +func RunUninstall() error { + return uninstallCmd.RunE(uninstallCmd, nil) +} diff --git a/internal/commands/mago/register_test.go b/internal/commands/mago/register_test.go new file mode 100644 index 0000000..d5eb277 --- /dev/null +++ b/internal/commands/mago/register_test.go @@ -0,0 +1,21 @@ +package mago + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "mago", Title: "Mago"}) + Register(root) + + expected := []string{"mago:install", "mago:download", "mago:path", "mago:update", "mago:uninstall"} + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/cmd/mago_uninstall.go b/internal/commands/mago/uninstall.go similarity index 76% rename from cmd/mago_uninstall.go rename to internal/commands/mago/uninstall.go index 71942c3..43818c1 100644 --- a/cmd/mago_uninstall.go +++ b/internal/commands/mago/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var magoUninstallCmd = &cobra.Command{ - Use: "mago:uninstall", - Short: "Remove Mago binary and PATH entry", +var uninstallCmd = &cobra.Command{ + Use: "mago:uninstall", + GroupID: "mago", + 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 { @@ -27,7 +28,3 @@ var magoUninstallCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(magoUninstallCmd) -} diff --git a/cmd/mago_update.go b/internal/commands/mago/update.go similarity index 79% rename from cmd/mago_update.go rename to internal/commands/mago/update.go index 6e03b9a..540d348 100644 --- a/cmd/mago_update.go +++ b/internal/commands/mago/update.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var magoUpdateCmd = &cobra.Command{ - Use: "mago:update", - Short: "Update Mago to the latest version", +var updateCmd = &cobra.Command{ + Use: "mago:update", + GroupID: "mago", + Short: "Update Mago to the latest version", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} @@ -32,7 +33,7 @@ var magoUpdateCmd = &cobra.Command{ } // Delegate download to :download. - if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -47,7 +48,3 @@ var magoUpdateCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(magoUpdateCmd) -} diff --git a/cmd/php_download.go b/internal/commands/php/download.go similarity index 55% rename from cmd/php_download.go rename to internal/commands/php/download.go index 180f433..f1ec792 100644 --- a/cmd/php_download.go +++ b/internal/commands/php/download.go @@ -1,28 +1,23 @@ -package cmd +package php 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), +var downloadCmd = &cobra.Command{ + Use: "php:download [version]", + GroupID: "php", + 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 + return fmt.Errorf("invalid version format %q, use major.minor (e.g., 8.4)", version) } client := &http.Client{} @@ -34,7 +29,3 @@ var phpDownloadCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(phpDownloadCmd) -} diff --git a/cmd/php_install.go b/internal/commands/php/install.go similarity index 71% rename from cmd/php_install.go rename to internal/commands/php/install.go index cd85f75..41d0144 100644 --- a/cmd/php_install.go +++ b/internal/commands/php/install.go @@ -1,9 +1,8 @@ -package cmd +package php import ( "fmt" "net/http" - "os" "regexp" "github.com/prvious/pv/internal/phpenv" @@ -14,10 +13,16 @@ import ( var validPHPVersion = regexp.MustCompile(`^\d+\.\d+$`) -var phpInstallCmd = &cobra.Command{ - Use: "php:install [version]", - Short: "Install a PHP version (e.g., pv php:install 8.4). Installs latest if omitted.", - Args: cobra.MaximumNArgs(1), +var installCmd = &cobra.Command{ + Use: "php:install [version]", + GroupID: "php", + Short: "Install a PHP version (e.g., pv php:install 8.4). Installs latest if omitted.", + Example: `# Install the latest PHP version +pv php:install + +# Install a specific version +pv php:install 8.3`, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { version := "" if len(args) > 0 { @@ -38,12 +43,7 @@ var phpInstallCmd = &cobra.Command{ } 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 + return fmt.Errorf("invalid version format %q, use major.minor (e.g., 8.4)", version) } if phpenv.IsInstalled(version) { @@ -53,16 +53,12 @@ var phpInstallCmd = &cobra.Command{ return err } } - fmt.Fprintln(os.Stderr) ui.Success(fmt.Sprintf("PHP %s is already installed", version)) - fmt.Fprintln(os.Stderr) return nil } - fmt.Fprintln(os.Stderr) - // Download. - if err := phpDownloadCmd.RunE(phpDownloadCmd, []string{version}); err != nil { + if err := downloadCmd.RunE(downloadCmd, []string{version}); err != nil { return err } @@ -84,11 +80,6 @@ var phpInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(phpInstallCmd) -} diff --git a/cmd/php_list.go b/internal/commands/php/list.go similarity index 86% rename from cmd/php_list.go rename to internal/commands/php/list.go index 27e5ef5..0811c8c 100644 --- a/cmd/php_list.go +++ b/internal/commands/php/list.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -11,9 +11,10 @@ import ( "github.com/spf13/cobra" ) -var phpListCmd = &cobra.Command{ - Use: "php:list", - Short: "List installed PHP versions", +var listCmd = &cobra.Command{ + Use: "php:list", + GroupID: "php", + Short: "List installed PHP versions", RunE: func(cmd *cobra.Command, args []string) error { versions, err := phpenv.InstalledVersions() if err != nil { @@ -21,7 +22,7 @@ var phpListCmd = &cobra.Command{ } if len(versions) == 0 { fmt.Fprintln(os.Stderr) - ui.Subtle("No PHP versions installed. Run: pv php:install ") + ui.Subtle("No PHP versions installed. Run: pv php:install [version]") fmt.Fprintln(os.Stderr) return nil } @@ -70,7 +71,3 @@ var phpListCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(phpListCmd) -} diff --git a/cmd/php_path.go b/internal/commands/php/path.go similarity index 71% rename from cmd/php_path.go rename to internal/commands/php/path.go index dc7a810..37a4319 100644 --- a/cmd/php_path.go +++ b/internal/commands/php/path.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -8,16 +8,17 @@ import ( "github.com/spf13/cobra" ) -var phpPathRemove bool +var pathRemove bool -var phpPathCmd = &cobra.Command{ - Use: "php:path", - Short: "Expose or remove PHP and FrankenPHP from PATH", +var pathCmd = &cobra.Command{ + Use: "php:path", + GroupID: "php", + 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 pathRemove { if err := tools.Unexpose(php); err != nil { return err } @@ -40,6 +41,5 @@ var phpPathCmd = &cobra.Command{ } func init() { - phpPathCmd.Flags().BoolVar(&phpPathRemove, "remove", false, "Remove from PATH instead of adding") - rootCmd.AddCommand(phpPathCmd) + pathCmd.Flags().BoolVar(&pathRemove, "remove", false, "Remove from PATH instead of adding") } diff --git a/internal/commands/php/register.go b/internal/commands/php/register.go new file mode 100644 index 0000000..f6b81f6 --- /dev/null +++ b/internal/commands/php/register.go @@ -0,0 +1,26 @@ +package php + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(installCmd) + parent.AddCommand(downloadCmd) + parent.AddCommand(pathCmd) + parent.AddCommand(updateCmd) + parent.AddCommand(uninstallCmd) + parent.AddCommand(useCmd) + parent.AddCommand(listCmd) + parent.AddCommand(removeCmd) +} + +func RunInstall(args []string) error { + return installCmd.RunE(installCmd, args) +} + +func RunUpdate() error { + return updateCmd.RunE(updateCmd, nil) +} + +func RunUninstall() error { + return uninstallCmd.RunE(uninstallCmd, nil) +} diff --git a/internal/commands/php/register_test.go b/internal/commands/php/register_test.go new file mode 100644 index 0000000..27a7c73 --- /dev/null +++ b/internal/commands/php/register_test.go @@ -0,0 +1,25 @@ +package php + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "php", Title: "PHP"}) + Register(root) + + expected := []string{ + "php:install", "php:download", "php:path", + "php:update", "php:uninstall", "php:use", + "php:list", "php:remove", + } + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/internal/commands/php/remove.go b/internal/commands/php/remove.go new file mode 100644 index 0000000..f6dac05 --- /dev/null +++ b/internal/commands/php/remove.go @@ -0,0 +1,46 @@ +package php + +import ( + "fmt" + "regexp" + + "github.com/prvious/pv/internal/phpenv" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "php:remove [version]", + GroupID: "php", + Short: "Remove an installed PHP version", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + version := args[0] + if !regexp.MustCompile(`^\d+\.\d+$`).MatchString(version) { + return fmt.Errorf("invalid version format %q, use major.minor (e.g., 8.4)", version) + } + + // Check if any linked projects depend on this version. + reg, err := registry.Load() + if err == nil { + globalV, _ := phpenv.GlobalVersion() + for _, p := range reg.List() { + v := p.PHP + if v == "" { + v = globalV + } + if v == version { + return fmt.Errorf("cannot remove PHP %s, project %q depends on it", version, p.Name) + } + } + } + + return ui.Step("Removing PHP "+version+"...", func() (string, error) { + if err := phpenv.Remove(version); err != nil { + return "", err + } + return fmt.Sprintf("PHP %s removed", version), nil + }) + }, +} diff --git a/cmd/php_uninstall.go b/internal/commands/php/uninstall.go similarity index 78% rename from cmd/php_uninstall.go rename to internal/commands/php/uninstall.go index ca1dd98..388f623 100644 --- a/cmd/php_uninstall.go +++ b/internal/commands/php/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var phpUninstallCmd = &cobra.Command{ - Use: "php:uninstall", - Short: "Remove all PHP versions and PATH entries", +var uninstallCmd = &cobra.Command{ + Use: "php:uninstall", + GroupID: "php", + 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"} { @@ -29,7 +30,3 @@ var phpUninstallCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(phpUninstallCmd) -} diff --git a/cmd/php_update.go b/internal/commands/php/update.go similarity index 85% rename from cmd/php_update.go rename to internal/commands/php/update.go index e88b74c..bf3032e 100644 --- a/cmd/php_update.go +++ b/internal/commands/php/update.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var phpUpdateCmd = &cobra.Command{ - Use: "php:update", - Short: "Re-download all installed PHP versions with the latest builds", +var updateCmd = &cobra.Command{ + Use: "php:update", + GroupID: "php", + Short: "Re-download all installed PHP versions with the latest builds", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} @@ -50,7 +51,3 @@ var phpUpdateCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(phpUpdateCmd) -} diff --git a/cmd/php_use.go b/internal/commands/php/use.go similarity index 67% rename from cmd/php_use.go rename to internal/commands/php/use.go index a74e394..e0a963f 100644 --- a/cmd/php_use.go +++ b/internal/commands/php/use.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -11,10 +11,13 @@ import ( "github.com/spf13/cobra" ) -var phpUseCmd = &cobra.Command{ - Use: "php:use ", - Short: "Switch the global PHP version (e.g., pv php:use 8.4)", - Args: cobra.ExactArgs(1), +var useCmd = &cobra.Command{ + Use: "php:use [version]", + GroupID: "php", + Short: "Switch the global PHP version (e.g., pv php:use 8.4)", + Example: `pv php:use 8.4 +pv php:use 8.3`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { version := args[0] if version == "" { @@ -22,12 +25,7 @@ var phpUseCmd = &cobra.Command{ } if !phpenv.IsInstalled(version) { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("PHP %s is not installed", ui.Bold.Render(version))) - ui.FailDetail(fmt.Sprintf("Run: pv php:install %s", version)) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("PHP %s is not installed, run: pv php:install %s", version, version) } oldV, _ := phpenv.GlobalVersion() @@ -52,10 +50,7 @@ var phpUseCmd = &cobra.Command{ if oldV != version && daemon.IsLoaded() { cfg := daemon.DefaultPlistConfig() if err := daemon.SyncIfNeeded(cfg); err != nil { - fmt.Fprintf(os.Stderr, " %s %s\n", - ui.Red.Render("!"), - ui.Muted.Render(fmt.Sprintf("Cannot sync daemon plist: %v", err)), - ) + ui.Fail(fmt.Sprintf("Cannot sync daemon plist: %v", err)) } else { ui.Success("Daemon restarted with new PHP version") } @@ -64,11 +59,6 @@ var phpUseCmd = &cobra.Command{ ui.Subtle("Run: pv restart") } - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(phpUseCmd) -} diff --git a/cmd/service_add.go b/internal/commands/service/add.go similarity index 84% rename from cmd/service_add.go rename to internal/commands/service/add.go index 42dddb5..b14b147 100644 --- a/cmd/service_add.go +++ b/internal/commands/service/add.go @@ -1,11 +1,13 @@ -package cmd +package service import ( + "errors" "fmt" "os" "github.com/prvious/pv/internal/caddy" "github.com/prvious/pv/internal/colima" + colimacmds "github.com/prvious/pv/internal/commands/colima" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/container" "github.com/prvious/pv/internal/registry" @@ -14,11 +16,20 @@ import ( "github.com/spf13/cobra" ) -var serviceAddCmd = &cobra.Command{ - Use: "service:add [version]", - Short: "Add and start a service", - Long: "Add a backing service (mail, mysql, postgres, redis, s3). Optionally specify a version.", - Args: cobra.RangeArgs(1, 2), +var addCmd = &cobra.Command{ + Use: "service:add [version]", + GroupID: "service", + Short: "Add and start a service", + Long: "Add a backing service (mail, mysql, postgres, redis, s3). Optionally specify a version.", + Example: `# Add MySQL with default version +pv service:add mysql + +# Add a specific Redis version +pv service:add redis 7 + +# Add PostgreSQL +pv service:add postgres 16`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { svcName := args[0] svc, err := services.Lookup(svcName) @@ -53,7 +64,7 @@ var serviceAddCmd = &cobra.Command{ // Ensure Colima is installed (lazy install on first service:add). containerReady := false if !colima.IsInstalled() { - if err := colimaInstallCmd.RunE(colimaInstallCmd, nil); err != nil { + if err := colimacmds.RunInstall(); err != nil { return fmt.Errorf("cannot install Colima (required for services): %w", err) } } @@ -72,7 +83,9 @@ var serviceAddCmd = &cobra.Command{ _ = 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)) + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Subtle(fmt.Sprintf("Image pull skipped: %v", err)) + } } else { // Create and start container. if err := ui.Step(fmt.Sprintf("Starting %s %s...", svc.DisplayName(), version), func() (string, error) { @@ -81,7 +94,9 @@ var serviceAddCmd = &cobra.Command{ 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)) + if !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Subtle(fmt.Sprintf("Container start skipped: %v", err)) + } } else { containerReady = true } @@ -148,7 +163,3 @@ var serviceAddCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceAddCmd) -} diff --git a/cmd/service_destroy.go b/internal/commands/service/destroy.go similarity index 78% rename from cmd/service_destroy.go rename to internal/commands/service/destroy.go index 29fb6d5..47c536e 100644 --- a/cmd/service_destroy.go +++ b/internal/commands/service/destroy.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -12,10 +12,11 @@ import ( "github.com/spf13/cobra" ) -var serviceDestroyCmd = &cobra.Command{ - Use: "service:destroy ", - Short: "Stop, remove container, and delete all data for a service", - Args: cobra.ExactArgs(1), +var destroyCmd = &cobra.Command{ + Use: "service:destroy ", + GroupID: "service", + Short: "Stop, remove container, and delete all data for a service", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] @@ -26,15 +27,9 @@ var serviceDestroyCmd = &cobra.Command{ svc := reg.FindService(key) if svc == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("service %q not found", key) } - fmt.Fprintln(os.Stderr) - // Determine service name and version from key. svcName := key version := "latest" @@ -83,7 +78,3 @@ var serviceDestroyCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceDestroyCmd) -} diff --git a/cmd/service_env.go b/internal/commands/service/env.go similarity index 72% rename from cmd/service_env.go rename to internal/commands/service/env.go index 134bb7c..39b98f1 100644 --- a/cmd/service_env.go +++ b/internal/commands/service/env.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -12,10 +12,11 @@ import ( "github.com/spf13/cobra" ) -var serviceEnvCmd = &cobra.Command{ - Use: "service:env [service]", - Short: "Print environment variables for a service", - Args: cobra.MaximumNArgs(1), +var envCmd = &cobra.Command{ + Use: "service:env [service]", + GroupID: "service", + Short: "Print environment variables for a service", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { @@ -24,7 +25,7 @@ var serviceEnvCmd = &cobra.Command{ // Determine project name from current directory. cwd, _ := os.Getwd() - projectName := sanitizeProjectName(filepath.Base(cwd)) + projectName := services.SanitizeProjectName(filepath.Base(cwd)) if len(args) == 0 { // Print env for all services. @@ -44,6 +45,7 @@ var serviceEnvCmd = &cobra.Command{ } svc, err := services.Lookup(svcName) if err != nil { + ui.Subtle(fmt.Sprintf("Skipping unknown service %q", svcName)) continue } envVars := svc.EnvVars(projectName, instance.Port) @@ -55,11 +57,7 @@ var serviceEnvCmd = &cobra.Command{ key := args[0] instance := reg.FindService(key) if instance == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("service %q not found", key) } svcName := key @@ -86,12 +84,3 @@ func printEnvVars(key string, envVars map[string]string) { } fmt.Fprintln(os.Stderr) } - -// sanitizeProjectName converts a directory name to a database-safe name. -func sanitizeProjectName(name string) string { - return strings.ReplaceAll(name, "-", "_") -} - -func init() { - rootCmd.AddCommand(serviceEnvCmd) -} diff --git a/cmd/service_list.go b/internal/commands/service/list.go similarity index 89% rename from cmd/service_list.go rename to internal/commands/service/list.go index 985d959..cead375 100644 --- a/cmd/service_list.go +++ b/internal/commands/service/list.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -10,9 +10,10 @@ import ( "github.com/spf13/cobra" ) -var serviceListCmd = &cobra.Command{ - Use: "service:list", - Short: "List all services", +var listCmd = &cobra.Command{ + Use: "service:list", + GroupID: "service", + Short: "List all services", RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { @@ -62,7 +63,3 @@ var serviceListCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceListCmd) -} diff --git a/internal/commands/service/logs.go b/internal/commands/service/logs.go new file mode 100644 index 0000000..b4be9e3 --- /dev/null +++ b/internal/commands/service/logs.go @@ -0,0 +1,39 @@ +package service + +import ( + "fmt" + + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var logsCmd = &cobra.Command{ + Use: "service:logs ", + GroupID: "service", + Short: "Tail container logs for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + instance := reg.FindService(key) + if instance == nil { + return fmt.Errorf("service %q not found", key) + } + + if instance.ContainerID == "" { + return fmt.Errorf("service %q is not running, start it first: pv service:start %s", key, key) + } + + // Docker SDK: ContainerLogs with Follow=true + // This would stream logs to stdout. + ui.Subtle(fmt.Sprintf("Tailing logs for %s (container: %s)...", key, instance.ContainerID)) + + return nil + }, +} diff --git a/internal/commands/service/register.go b/internal/commands/service/register.go new file mode 100644 index 0000000..52dd926 --- /dev/null +++ b/internal/commands/service/register.go @@ -0,0 +1,19 @@ +package service + +import "github.com/spf13/cobra" + +func Register(parent *cobra.Command) { + parent.AddCommand(addCmd) + parent.AddCommand(startCmd) + parent.AddCommand(stopCmd) + parent.AddCommand(statusCmd) + parent.AddCommand(listCmd) + parent.AddCommand(envCmd) + parent.AddCommand(removeCmd) + parent.AddCommand(destroyCmd) + parent.AddCommand(logsCmd) +} + +func RunAdd(args []string) error { + return addCmd.RunE(addCmd, args) +} diff --git a/internal/commands/service/register_test.go b/internal/commands/service/register_test.go new file mode 100644 index 0000000..dea703f --- /dev/null +++ b/internal/commands/service/register_test.go @@ -0,0 +1,25 @@ +package service + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestRegister_AllCommandsPresent(t *testing.T) { + root := &cobra.Command{Use: "pv"} + root.AddGroup(&cobra.Group{ID: "service", Title: "Services"}) + Register(root) + + expected := []string{ + "service:add", "service:start", "service:stop", + "service:status", "service:list", "service:env", + "service:remove", "service:destroy", "service:logs", + } + for _, name := range expected { + cmd, _, err := root.Find([]string{name}) + if err != nil || cmd.Name() != name { + t.Errorf("command %q not registered", name) + } + } +} diff --git a/cmd/service_remove.go b/internal/commands/service/remove.go similarity index 72% rename from cmd/service_remove.go rename to internal/commands/service/remove.go index 74b583c..c8e27b9 100644 --- a/cmd/service_remove.go +++ b/internal/commands/service/remove.go @@ -1,8 +1,7 @@ -package cmd +package service import ( "fmt" - "os" "strings" "github.com/prvious/pv/internal/caddy" @@ -12,10 +11,11 @@ import ( "github.com/spf13/cobra" ) -var serviceRemoveCmd = &cobra.Command{ - Use: "service:remove ", - Short: "Stop and remove a service container (data preserved)", - Args: cobra.ExactArgs(1), +var removeCmd = &cobra.Command{ + Use: "service:remove ", + GroupID: "service", + Short: "Stop and remove a service container (data preserved)", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] @@ -26,15 +26,9 @@ var serviceRemoveCmd = &cobra.Command{ svc := reg.FindService(key) if svc == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("service %q not found", key) } - fmt.Fprintln(os.Stderr) - if err := ui.Step(fmt.Sprintf("Removing %s...", key), func() (string, error) { // Docker SDK: stop + remove container. return fmt.Sprintf("%s removed", key), nil @@ -61,15 +55,9 @@ var serviceRemoveCmd = &cobra.Command{ } dataDir := config.ServiceDataDir(svcName, version) - fmt.Fprintln(os.Stderr) ui.Subtle(fmt.Sprintf("Data preserved at %s", dataDir)) ui.Subtle(fmt.Sprintf("Run 'pv service:add %s %s' to start it again.", svcName, version)) - fmt.Fprintln(os.Stderr) return nil }, } - -func init() { - rootCmd.AddCommand(serviceRemoveCmd) -} diff --git a/cmd/service_start.go b/internal/commands/service/start.go similarity index 74% rename from cmd/service_start.go rename to internal/commands/service/start.go index 00c9fa8..f4db6e8 100644 --- a/cmd/service_start.go +++ b/internal/commands/service/start.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -10,10 +10,11 @@ import ( "github.com/spf13/cobra" ) -var serviceStartCmd = &cobra.Command{ - Use: "service:start [service]", - Short: "Start a service or all services", - Args: cobra.MaximumNArgs(1), +var startCmd = &cobra.Command{ + Use: "service:start [service]", + GroupID: "service", + Short: "Start a service or all services", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { @@ -48,12 +49,7 @@ var serviceStartCmd = &cobra.Command{ } else { key := args[0] if reg.FindService(key) == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - ui.FailDetail("Run 'pv service:list' to see available services") - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("service %q not found, run 'pv service:list' to see available services", key) } if err := ui.Step(fmt.Sprintf("Starting %s...", key), func() (string, error) { @@ -68,7 +64,3 @@ var serviceStartCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceStartCmd) -} diff --git a/internal/commands/service/status.go b/internal/commands/service/status.go new file mode 100644 index 0000000..8235eab --- /dev/null +++ b/internal/commands/service/status.go @@ -0,0 +1,70 @@ +package service + +import ( + "fmt" + "strings" + + "github.com/prvious/pv/internal/config" + "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/services" + "github.com/prvious/pv/internal/ui" + "github.com/spf13/cobra" +) + +var statusCmd = &cobra.Command{ + Use: "service:status ", + GroupID: "service", + Short: "Show detailed status for a service", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + reg, err := registry.Load() + if err != nil { + return fmt.Errorf("cannot load registry: %w", err) + } + + instance := reg.FindService(key) + if instance == nil { + return fmt.Errorf("service %q not found", key) + } + + // Parse service name and version from key. + svcName := key + version := "latest" + if idx := strings.Index(key, ":"); idx > 0 { + svcName = key[:idx] + version = key[idx+1:] + } + + svc, err := services.Lookup(svcName) + if err != nil { + return err + } + + status := "stopped" + if instance.ContainerID != "" { + status = "running" + } + + dataDir := config.ServiceDataDir(svcName, version) + projects := reg.ProjectsUsingService(svcName) + + rows := [][]string{ + {"Status", status}, + {"Container", svc.ContainerName(version)}, + {"Port", fmt.Sprintf(":%d", instance.Port)}, + } + if instance.ConsolePort > 0 { + rows = append(rows, []string{"Console", fmt.Sprintf(":%d", instance.ConsolePort)}) + } + rows = append(rows, []string{"Data", dataDir}) + if len(projects) > 0 { + rows = append(rows, []string{"Projects", strings.Join(projects, ", ")}) + } + + ui.Table([]string{svc.DisplayName(), version}, rows) + + return nil + }, +} diff --git a/cmd/service_stop.go b/internal/commands/service/stop.go similarity index 69% rename from cmd/service_stop.go rename to internal/commands/service/stop.go index d452c39..9431661 100644 --- a/cmd/service_stop.go +++ b/internal/commands/service/stop.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -9,10 +9,11 @@ import ( "github.com/spf13/cobra" ) -var serviceStopCmd = &cobra.Command{ - Use: "service:stop [service]", - Short: "Stop a service or all services", - Args: cobra.MaximumNArgs(1), +var stopCmd = &cobra.Command{ + Use: "service:stop [service]", + GroupID: "service", + Short: "Stop a service or all services", + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { @@ -40,12 +41,7 @@ var serviceStopCmd = &cobra.Command{ } else { key := args[0] if reg.FindService(key) == nil { - fmt.Fprintln(os.Stderr) - ui.Fail(fmt.Sprintf("Service %s not found", ui.Bold.Render(key))) - ui.FailDetail("Run 'pv service:list' to see available services") - fmt.Fprintln(os.Stderr) - cmd.SilenceUsage = true - return ui.ErrAlreadyPrinted + return fmt.Errorf("service %q not found, run 'pv service:list' to see available services", key) } if err := ui.Step(fmt.Sprintf("Stopping %s...", key), func() (string, error) { @@ -60,7 +56,3 @@ var serviceStopCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceStopCmd) -} diff --git a/internal/container/engine.go b/internal/container/engine.go index fb260a3..c2e5217 100644 --- a/internal/container/engine.go +++ b/internal/container/engine.go @@ -2,14 +2,14 @@ package container // CreateOpts defines parameters for creating a Docker container. type CreateOpts struct { - Name string - Image string - Env []string - Ports map[int]int // host:container - Volumes map[string]string // host:container - Labels map[string]string - Cmd []string - HealthCmd []string + Name string + Image string + Env []string + Ports map[int]int // host:container + Volumes map[string]string // host:container + Labels map[string]string + Cmd []string + HealthCmd []string HealthInterval string HealthTimeout string HealthRetries int diff --git a/internal/detection/detect_test.go b/internal/detection/detect_test.go index a254db9..9ade56e 100644 --- a/internal/detection/detect_test.go +++ b/internal/detection/detect_test.go @@ -24,7 +24,7 @@ func scaffold(t *testing.T, files map[string]string) string { func TestDetect_LaravelOctane(t *testing.T) { dir := scaffold(t, map[string]string{ - "composer.json": `{"require":{"laravel/framework":"^11.0","laravel/octane":"^2.0"}}`, + "composer.json": `{"require":{"laravel/framework":"^11.0","laravel/octane":"^2.0"}}`, "public/frankenphp-worker.php": ")") + return "", fmt.Errorf("no global PHP version set (run: pv php:install [version])") } return settings.GlobalPHP, nil } diff --git a/internal/services/mail.go b/internal/services/mail.go index b34f5cd..757f651 100644 --- a/internal/services/mail.go +++ b/internal/services/mail.go @@ -20,7 +20,7 @@ func (m *Mail) ContainerName(version string) string { return "pv-mail-" + version } -func (m *Mail) Port(_ string) int { return 1025 } +func (m *Mail) Port(_ string) int { return 1025 } func (m *Mail) ConsolePort(_ string) int { return 8025 } func (m *Mail) WebRoutes() []WebRoute { diff --git a/internal/services/mysql.go b/internal/services/mysql.go index fd7591a..7280997 100644 --- a/internal/services/mysql.go +++ b/internal/services/mysql.go @@ -40,8 +40,8 @@ func (m *MySQL) Port(version string) int { return 33000 } -func (m *MySQL) ConsolePort(_ string) int { return 0 } -func (m *MySQL) WebRoutes() []WebRoute { return nil } +func (m *MySQL) ConsolePort(_ string) int { return 0 } +func (m *MySQL) WebRoutes() []WebRoute { return nil } func (m *MySQL) CreateOpts(version string) container.CreateOpts { port := m.Port(version) diff --git a/internal/services/s3.go b/internal/services/s3.go index 5a402dc..2f01656 100644 --- a/internal/services/s3.go +++ b/internal/services/s3.go @@ -20,7 +20,7 @@ func (s *S3) ContainerName(version string) string { return "pv-s3-" + version } -func (s *S3) Port(_ string) int { return 9000 } +func (s *S3) Port(_ string) int { return 9000 } func (s *S3) ConsolePort(_ string) int { return 9001 } func (s *S3) WebRoutes() []WebRoute { diff --git a/internal/services/service.go b/internal/services/service.go index 41c4963..74545f7 100644 --- a/internal/services/service.go +++ b/internal/services/service.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "strings" "github.com/prvious/pv/internal/container" ) @@ -48,6 +49,11 @@ func Available() []string { return []string{"mail", "mysql", "postgres", "redis", "s3"} } +// SanitizeProjectName converts a directory name to a database-safe name. +func SanitizeProjectName(name string) string { + return strings.ReplaceAll(name, "-", "_") +} + // ServiceKey returns the registry key for a service instance. // For versioned services: "mysql:8.0.32". For unversioned: "redis". func ServiceKey(name, version string) string { diff --git a/internal/tools/shims.go b/internal/tools/shims.go index a803a16..dfdb739 100644 --- a/internal/tools/shims.go +++ b/internal/tools/shims.go @@ -57,7 +57,7 @@ resolve_version() { VERSION=$(resolve_version) if [ -z "$VERSION" ]; then - echo "pv: no PHP version configured. Run: pv php:install " >&2 + echo "pv: no PHP version configured. Run: pv php:install [version]" >&2 exit 1 fi @@ -83,4 +83,3 @@ func writePhpShim() error { } return nil } - diff --git a/internal/tools/tool.go b/internal/tools/tool.go index 940592f..51e6a3f 100644 --- a/internal/tools/tool.go +++ b/internal/tools/tool.go @@ -20,9 +20,9 @@ const ( // Tool describes a managed binary. type Tool struct { - Name string // set automatically from registry key + Name string // set automatically from registry key DisplayName string - AutoExpose bool // :install auto-calls :path + AutoExpose bool // :install auto-calls :path Exposure ExposureType // InternalPath returns where the real binary lives. InternalPath func() string diff --git a/internal/ui/progress.go b/internal/ui/progress.go index 1649181..bd684c3 100644 --- a/internal/ui/progress.go +++ b/internal/ui/progress.go @@ -10,9 +10,9 @@ const progressBarWidth = 40 // ProgressWriter wraps an io.Writer and displays a progress bar. type ProgressWriter struct { - total int64 - written int64 - label string + total int64 + written int64 + label string lastDraw time.Time } @@ -68,4 +68,3 @@ func (pw *ProgressWriter) draw() { func (pw *ProgressWriter) Finish() { fmt.Fprintf(os.Stderr, "\r\033[2K\033[?25h") } - diff --git a/internal/ui/spinner.go b/internal/ui/spinner.go index 11b1886..0af00bf 100644 --- a/internal/ui/spinner.go +++ b/internal/ui/spinner.go @@ -87,7 +87,7 @@ func Step(label string, fn func() (string, error)) error { if err != nil { Fail(label) FailDetail(err.Error()) - return err + return ErrAlreadyPrinted } Success(result) @@ -102,7 +102,7 @@ func StepVerbose(label string, fn func() (string, error)) error { if err != nil { Fail(label) FailDetail(err.Error()) - return err + return ErrAlreadyPrinted } Success(result) @@ -138,7 +138,7 @@ func StepProgress(label string, fn func(progress func(written, total int64)) (st if err != nil { Fail(label) FailDetail(err.Error()) - return err + return ErrAlreadyPrinted } Success(result) diff --git a/internal/ui/spinner_test.go b/internal/ui/spinner_test.go new file mode 100644 index 0000000..20ba2f7 --- /dev/null +++ b/internal/ui/spinner_test.go @@ -0,0 +1,61 @@ +package ui + +import ( + "errors" + "fmt" + "testing" +) + +func TestStep_Success(t *testing.T) { + err := Step("test", func() (string, error) { + return "done", nil + }) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestStep_ReturnsErrAlreadyPrinted(t *testing.T) { + err := Step("test", func() (string, error) { + return "", fmt.Errorf("inner error") + }) + if !errors.Is(err, ErrAlreadyPrinted) { + t.Errorf("expected ErrAlreadyPrinted, got %v", err) + } +} + +func TestStepVerbose_Success(t *testing.T) { + err := StepVerbose("test", func() (string, error) { + return "done", nil + }) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestStepVerbose_ReturnsErrAlreadyPrinted(t *testing.T) { + err := StepVerbose("test", func() (string, error) { + return "", fmt.Errorf("inner error") + }) + if !errors.Is(err, ErrAlreadyPrinted) { + t.Errorf("expected ErrAlreadyPrinted, got %v", err) + } +} + +func TestStepProgress_Success(t *testing.T) { + err := StepProgress("test", func(progress func(written, total int64)) (string, error) { + return "done", nil + }) + if err != nil { + t.Errorf("expected nil, got %v", err) + } +} + +func TestStepProgress_ReturnsErrAlreadyPrinted(t *testing.T) { + err := StepProgress("test", func(progress func(written, total int64)) (string, error) { + return "", fmt.Errorf("inner error") + }) + if !errors.Is(err, ErrAlreadyPrinted) { + t.Errorf("expected ErrAlreadyPrinted, got %v", err) + } +} diff --git a/internal/ui/style.go b/internal/ui/style.go index a24e4df..3abab54 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/charmbracelet/lipgloss" + "charm.land/lipgloss/v2" ) // Colors matching install.sh: PURPLE=#b39ddb (ANSI 141), GREEN, RED, MUTED (dim). @@ -19,7 +19,7 @@ var ( // ErrAlreadyPrinted is returned when the error has already been displayed // to the user via styled output. Callers should exit without printing again. -var ErrAlreadyPrinted = errors.New("") +var ErrAlreadyPrinted = errors.New("error already printed") // Header prints the pv version banner. func Header(version string) { @@ -49,8 +49,7 @@ func FailDetail(text string) { fmt.Fprintf(os.Stderr, " %s\n", Muted.Render(text)) } -// Fatal prints an error and exits. -func Fatal(err error) { - Fail(err.Error()) - os.Exit(1) +// SectionHeader prints a bold section header with surrounding spacing. +func SectionHeader(text string) { + fmt.Fprintf(os.Stderr, "\n %s\n", Bold.Render(text)) } diff --git a/internal/ui/tree.go b/internal/ui/tree.go index b682144..f20c2a6 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -5,8 +5,8 @@ import ( "os" "strings" - "github.com/charmbracelet/lipgloss" - "github.com/charmbracelet/lipgloss/table" + "charm.land/lipgloss/v2" + "charm.land/lipgloss/v2/table" ) // TreeItem represents one node in the tree with a title and detail line. @@ -37,8 +37,8 @@ func Tree(items []TreeItem) { } var ( - purple = lipgloss.ANSIColor(141) - gray = lipgloss.ANSIColor(245) + purple = lipgloss.ANSIColor(141) + gray = lipgloss.ANSIColor(245) lightGray = lipgloss.ANSIColor(241) headerStyle = lipgloss.NewStyle().Foreground(purple).Bold(true) diff --git a/scripts/e2e/helpers.sh b/scripts/e2e/helpers.sh index e5c5dfb..05410d8 100755 --- a/scripts/e2e/helpers.sh +++ b/scripts/e2e/helpers.sh @@ -28,12 +28,19 @@ curl_site() { exit 1 } +# strip_ansi removes ANSI escape codes from text. +# lipgloss v2 always emits ANSI codes even when output is piped/captured. +strip_ansi() { + local esc=$'\x1b' + sed "s/${esc}\[[0-9;]*m//g" +} + # assert_contains TEXT PATTERN MSG — grep TEXT for PATTERN or fail with MSG. assert_contains() { local text="$1" local pattern="$2" local msg="$3" - echo "$text" | grep -q "$pattern" || { echo "FAIL: $msg"; exit 1; } + echo "$text" | strip_ansi | grep -q "$pattern" || { echo "FAIL: $msg"; exit 1; } } # assert_fails CMD... — run CMD, expect non-zero exit. diff --git a/scripts/e2e/uninstall.sh b/scripts/e2e/uninstall.sh index 48f847c..d5a7554 100755 --- a/scripts/e2e/uninstall.sh +++ b/scripts/e2e/uninstall.sh @@ -13,9 +13,9 @@ echo "OK: ~/.pv exists" PV_BIN=$(which pv) echo "pv binary at: $PV_BIN" -# Run uninstall by piping "uninstall" and "n" (decline auth backup). +# Run uninstall non-interactively with --force. # Don't wrap in sudo — pv handles sudo internally via sudo -n. -printf 'uninstall\nn\n' | pv uninstall +pv uninstall --force # Verify ~/.pv is gone. echo "==> Post-uninstall checks"