Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
330 changes: 202 additions & 128 deletions cmd/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,196 +3,270 @@ package cmd
import (
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"time"

"github.com/prvious/pv/internal/binaries"
"github.com/prvious/pv/internal/caddy"
"github.com/prvious/pv/internal/config"
"github.com/prvious/pv/internal/phpenv"
"github.com/prvious/pv/internal/registry"
"github.com/prvious/pv/internal/setup"
"github.com/prvious/pv/internal/ui"
"github.com/spf13/cobra"
)

var (
forceInstall bool
installTLD string
installPHP string
forceInstall bool
installTLD string
installPHP string
installVerbose bool
)

var installCmd = &cobra.Command{
Use: "install",
Short: "Set up pv for the first time",
RunE: func(cmd *cobra.Command, args []string) error {
start := time.Now()

// Propagate verbose flag.
binaries.Verbose = installVerbose
phpenv.Verbose = installVerbose
setup.Verbose = installVerbose

// Print header.
ui.Header(version)

// 0. Validate TLD.
if err := config.ValidateTLD(installTLD); err != nil {
return err
}

// 1. Check prerequisites.
fmt.Println("Checking prerequisites...")
if err := setup.CheckOS(); err != nil {
return err
}
fmt.Printf(" ✓ macOS detected (%s)\n", setup.PlatformLabel())

if setup.IsAlreadyInstalled() && !forceInstall {
return fmt.Errorf("pv is already installed at %s\n Run with --force to reinstall", config.PvDir())
}

// 2. Create directory structure.
fmt.Println("\nCreating directory structure...")
if err := config.EnsureDirs(); err != nil {
return fmt.Errorf("cannot create directories: %w", err)
}
fmt.Println(" ✓ ~/.pv directories created")

// Save TLD setting.
settings := &config.Settings{TLD: installTLD}
if err := settings.Save(); err != nil {
return fmt.Errorf("cannot save settings: %w", err)
// Acquire sudo credentials upfront so password prompt doesn't
// interfere with spinner output during DNS/CA steps.
ui.Subtle("pv needs sudo for DNS and certificate setup.")
sudoCmd := exec.Command("sudo", "-v")
sudoCmd.Stdin = os.Stdin
sudoCmd.Stdout = os.Stderr
sudoCmd.Stderr = os.Stderr
if err := sudoCmd.Run(); err != nil {
return fmt.Errorf("sudo authentication failed: %w", err)
}
fmt.Printf(" ✓ TLD set to .%s\n", installTLD)
fmt.Fprintln(os.Stderr)

client := &http.Client{}

// 3. Install PHP version via phpenv.
// Step 1: Check prerequisites.
if err := ui.Step("Checking prerequisites...", func() (string, error) {
if err := setup.CheckOS(); err != nil {
return "", err
}
return fmt.Sprintf("macOS %s", setup.PlatformLabel()), nil
}); err != nil {
return err
}

// Step 2: Install PHP (with progress bar for large downloads).
phpVersion := installPHP
if phpVersion == "" {
fmt.Println("\nDetecting available PHP versions...")
available, err := phpenv.AvailableVersions(client)
if err != nil {
return fmt.Errorf("cannot detect available PHP versions: %w", err)
var fullPHPResult string
if err := ui.StepProgress("Installing PHP...", func(progress func(written, total int64)) (string, error) {
if phpVersion == "" {
available, err := phpenv.AvailableVersions(client)
if err != nil {
return "", fmt.Errorf("cannot detect available PHP versions: %w", err)
}
if len(available) == 0 {
return "", fmt.Errorf("no PHP versions found in releases")
}
phpVersion = available[len(available)-1]
}
if len(available) == 0 {
return fmt.Errorf("no PHP versions found in releases")

// Create directory structure first.
if err := config.EnsureDirs(); err != nil {
return "", fmt.Errorf("cannot create directories: %w", err)
}
// Pick the highest available version.
phpVersion = available[len(available)-1]
fmt.Printf(" Latest available: PHP %s\n", phpVersion)
}

fmt.Printf("\nInstalling PHP %s...\n", phpVersion)
if err := phpenv.Install(client, phpVersion); err != nil {
return fmt.Errorf("cannot install PHP %s: %w", phpVersion, err)
}
// Save TLD setting.
settings := &config.Settings{TLD: installTLD}
if err := settings.Save(); err != nil {
return "", fmt.Errorf("cannot save settings: %w", err)
}

// Set as global default.
if err := phpenv.SetGlobal(phpVersion); err != nil {
return fmt.Errorf("cannot set global PHP: %w", err)
}
fmt.Printf(" ✓ PHP %s set as global default\n", phpVersion)
// Install PHP version with progress tracking.
if err := phpenv.InstallProgress(client, phpVersion, progress); err != nil {
return "", fmt.Errorf("cannot install PHP %s: %w", phpVersion, err)
}

// 4. Download other tools (Mago, Composer).
fmt.Println("\nDownloading tools...")
vs, err := binaries.LoadVersions()
if err != nil {
return fmt.Errorf("cannot load version state: %w", err)
// Set as global default.
if err := phpenv.SetGlobal(phpVersion); err != nil {
return "", fmt.Errorf("cannot set global PHP: %w", err)
}

// Detect full version for display.
fullVersion, err := binaries.DetectPHPVersion(config.PhpVersionDir(phpVersion))
if err != nil {
fullPHPResult = fmt.Sprintf("PHP %s (FrankenPHP + CLI)", phpVersion)
} else {
fullPHPResult = fmt.Sprintf("PHP %s (FrankenPHP + CLI)", fullVersion)
}
return fullPHPResult, nil
}); err != nil {
return err
}

for _, b := range binaries.Tools() {
latest, err := binaries.FetchLatestVersion(client, b)
// Step 3: Install tools (Mago, Composer).
var toolVersions []string
if err := ui.Step("Installing tools...", func() (string, error) {
vs, err := binaries.LoadVersions()
if err != nil {
return fmt.Errorf("cannot check %s version: %w", b.DisplayName, err)
return "", fmt.Errorf("cannot load version state: %w", err)
}

if err := binaries.InstallBinary(client, b, latest); err != nil {
return fmt.Errorf("cannot install %s: %w", b.DisplayName, err)
for _, b := range binaries.Tools() {
latest, err := binaries.FetchLatestVersion(client, b)
if err != nil {
return "", fmt.Errorf("cannot check %s version: %w", b.DisplayName, err)
}

if err := binaries.InstallBinary(client, b, latest); err != nil {
return "", fmt.Errorf("cannot install %s: %w", b.DisplayName, err)
}

vs.Set(b.Name, latest)
displayVersion := latest
if displayVersion == "latest" {
displayVersion = "installed"
}
toolVersions = append(toolVersions, fmt.Sprintf("%s %s", b.DisplayName, displayVersion))
}

vs.Set(b.Name, latest)
}
// Write shims.
if err := phpenv.WriteShims(); err != nil {
return "", fmt.Errorf("cannot write shims: %w", err)
}

// Write shims (PHP + Composer).
fmt.Println("\nWriting shims...")
if err := phpenv.WriteShims(); err != nil {
return fmt.Errorf("cannot write shims: %w", err)
}
fmt.Println(" ✓ PHP shim created")
fmt.Println(" ✓ Composer shim created")

// Migrate existing Composer config if present.
fmt.Println("\nChecking for existing Composer configuration...")
setup.MigrateComposerConfig()

// 5. Write version manifest.
fmt.Println("\nWriting version manifest...")
vs.Set("php", phpVersion)
if err := vs.Save(); err != nil {
return fmt.Errorf("cannot save versions: %w", err)
// Migrate existing Composer config if present.
setup.MigrateComposerConfig()

// Save version manifest.
vs.Set("php", phpVersion)
if err := vs.Save(); err != nil {
return "", fmt.Errorf("cannot save versions: %w", err)
}

return strings.Join(toolVersions, ", "), nil
}); err != nil {
return err
}
fmt.Println(" ✓ versions.json saved")

// 6. Create main Caddyfile.
fmt.Println("\nGenerating Caddyfile...")
if err := caddy.GenerateCaddyfile(); err != nil {
return fmt.Errorf("cannot generate Caddyfile: %w", err)
// Step 4: Configure environment.
if err := ui.Step("Configuring environment...", func() (string, error) {
// Generate Caddyfile.
if err := caddy.GenerateCaddyfile(); err != nil {
return "", fmt.Errorf("cannot generate Caddyfile: %w", err)
}

// Create empty registry.
reg := &registry.Registry{}
if err := reg.Save(); err != nil {
return "", fmt.Errorf("cannot save registry: %w", err)
}

return "Environment configured", nil
}); err != nil {
return err
}
fmt.Println(" ✓ Caddyfile created")

// 7. Create empty registry.
fmt.Println("\nInitializing registry...")
reg := &registry.Registry{}
if err := reg.Save(); err != nil {
return fmt.Errorf("cannot save registry: %w", err)
// Step 5: DNS resolver (sudo).
if err := ui.Step("Setting up DNS resolver...", func() (string, error) {
if err := setup.RunSudoResolver(installTLD); err != nil {
return "", fmt.Errorf("DNS resolver setup failed: %w", err)
}
return "DNS resolver configured", nil
}); err != nil {
return err
}
fmt.Println(" ✓ registry.json created")

// 8a. DNS resolver (sudo).
fmt.Println("\nSetting up DNS resolver...")
fmt.Println(" This requires administrator privileges.")
if err := setup.RunSudoResolver(installTLD); err != nil {
fmt.Printf(" x DNS resolver setup failed: %v\n", err)
fmt.Println(" You can set this up manually later:")
fmt.Println(" sudo mkdir -p /etc/resolver")
fmt.Printf(" echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/%s\n", installTLD)
} else {
fmt.Println(" ✓ DNS resolver configured")

// Step 6: Trust CA certificate (sudo).
if err := ui.Step("Trusting HTTPS certificate...", func() (string, error) {
if err := setup.RunSudoTrustWithServer(); err != nil {
return "", fmt.Errorf("CA trust failed: %w", err)
}
return "HTTPS certificate trusted", nil
}); err != nil {
return err
}

// 8b. Trust CA certificate (start server, trust, stop).
fmt.Println("\nTrusting CA certificate...")
if err := setup.RunSudoTrustWithServer(); err != nil {
fmt.Printf(" x CA trust failed: %v\n", err)
fmt.Println(" You can set this up manually later:")
fmt.Printf(" %s/frankenphp run --config %s --adapter caddyfile &\n", config.BinDir(), config.CaddyfilePath())
fmt.Printf(" sudo %s/frankenphp trust\n", config.BinDir())
fmt.Println(" kill %%1")
} else {
fmt.Println(" ✓ Caddy CA certificate trusted")
// Step 7: Self-test.
if err := ui.Step("Running self-test...", func() (string, error) {
results := setup.RunSelfTest(installTLD)
var failures []string
for _, r := range results {
if r.Err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", r.Name, r.Err))
}
}
if len(failures) > 0 {
return "", fmt.Errorf("self-test failures:\n %s", strings.Join(failures, "\n "))
}
return "All checks passed", nil
}); err != nil {
return err
}

// 9. Self-test.
fmt.Println("\nRunning self-test...")
results := setup.RunSelfTest(installTLD)
setup.PrintResults(results)

// 10. PATH instructions.
fmt.Println()
setup.PrintPathInstructions()

// 11. Summary.
fmt.Println()
fmt.Println("pv installed!")
fmt.Println()
fmt.Printf(" PHP: %s (global default)\n", phpVersion)
for _, b := range binaries.Tools() {
v := vs.Get(b.Name)
if v == "" {
v = "unknown"
}
fmt.Printf(" %-12s %s\n", b.DisplayName+":", v)
// Step 8: Shell PATH.
if err := ui.Step("Configuring shell...", func() (string, error) {
shell := setup.DetectShell()
configFile := setup.ShellConfigFile(shell)
line := setup.PathExportLine(shell)

// Check if already in PATH.
data, err := os.ReadFile(configFile)
if err == nil && strings.Contains(string(data), line) {
return "PATH already configured", nil
}

// Add to config file.
f, err := os.OpenFile(configFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return "", fmt.Errorf("cannot open %s: %w", configFile, err)
}
defer f.Close()
if _, err := fmt.Fprintf(f, "\n# pv\n%s\n", line); err != nil {
return "", fmt.Errorf("cannot write to %s: %w", configFile, err)
}

return fmt.Sprintf("Added to ~/%s", shortPath(configFile)), nil
}); err != nil {
return err
}
fmt.Println()
fmt.Println("Install additional PHP versions with: pv php install <version>")
fmt.Println("Run `pv link .` in a project to get started.")

// Footer.
ui.Footer(start, "https://pv.prvious.dev/docs")

return nil
},
}

// shortPath returns the path relative to HOME for display.
func shortPath(path string) string {
home, _ := os.UserHomeDir()
if strings.HasPrefix(path, home) {
return path[len(home)+1:]
}
return path
}

func init() {
installCmd.Flags().BoolVar(&forceInstall, "force", false, "Reinstall even if already installed")
installCmd.Flags().StringVar(&installTLD, "tld", "test", "Top-level domain for local sites (e.g., test, pv-test)")
installCmd.Flags().StringVar(&installPHP, "php", "", "PHP version to install (e.g., 8.4). Auto-detects latest if omitted.")
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Show detailed output")
rootCmd.AddCommand(installCmd)
}
Loading