From 3118639b1b681ea790f4a2985e01294c6cff9e1c Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 23:28:04 -0500 Subject: [PATCH 01/15] Upgrade to Charm v2 stack + wire fang for styled CLI output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migrate lipgloss v1 → v2 (charm.land/lipgloss/v2) - Migrate huh v0.8 → v2 (charm.land/huh/v2) - Add fang v2 (charm.land/fang/v2) for styled help/usage/errors - Replace manual Execute() error handling with fang.Execute() - Add pv color scheme using charmtone palette --- cmd/root.go | 29 ++++++++------ cmd/setup.go | 2 +- go.mod | 39 ++++++++++-------- go.sum | 95 +++++++++++++++++++++++--------------------- internal/ui/style.go | 2 +- internal/ui/tree.go | 4 +- 6 files changed, 92 insertions(+), 79 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index ae7c742..18a06ef 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,11 +1,12 @@ package cmd import ( - "errors" - "fmt" + "context" "os" - "github.com/prvious/pv/internal/ui" + "charm.land/fang/v2" + "charm.land/lipgloss/v2" + "github.com/charmbracelet/x/exp/charmtone" "github.com/spf13/cobra" ) @@ -15,19 +16,21 @@ 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 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), + ); err != nil { os.Exit(1) } } + +func pvColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { + cs := fang.DefaultColorScheme(c) + cs.Title = charmtone.Charple + return cs +} diff --git a/cmd/setup.go b/cmd/setup.go index 79db255..f8b7959 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -6,7 +6,7 @@ import ( "os" "time" - "github.com/charmbracelet/huh" + "charm.land/huh/v2" "github.com/prvious/pv/internal/binaries" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" diff --git a/go.mod b/go.mod index 553bbf7..edf1aaf 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/huh/v2 v2.0.0-20260226141913-a8934362ea3b + charm.land/lipgloss/v2 v2.0.0 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 + charm.land/fang/v2 v2.0.0-20260307033620-73847d32708b // 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/charmtone v0.0.0-20260305213658-fe36e8c10185 // 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..0b793c8 100644 --- a/go.sum +++ b/go.sum @@ -1,71 +1,78 @@ +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/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/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= @@ -84,10 +91,8 @@ 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= diff --git a/internal/ui/style.go b/internal/ui/style.go index a24e4df..492bd11 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). diff --git a/internal/ui/tree.go b/internal/ui/tree.go index b682144..a49673e 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. From 4d5f949e6e3d302ac186131a09ac7c9626acd6b0 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 23:29:44 -0500 Subject: [PATCH 02/15] Add Example fields to key commands for fang syntax highlighting - Move examples from install.go Long field to proper Example field - Clean up env.go Long field, add Example - Add examples to: link, unlink, log, php:install, php:use, service:add --- cmd/env.go | 11 +++++------ cmd/install.go | 17 +++++++++++------ cmd/link.go | 10 +++++++++- cmd/log.go | 10 +++++++++- cmd/php_install.go | 7 ++++++- cmd/php_use.go | 4 +++- cmd/service_add.go | 10 +++++++++- cmd/unlink.go | 7 ++++++- 8 files changed, 58 insertions(+), 18 deletions(-) diff --git a/cmd/env.go b/cmd/env.go index 39ca1bf..3cdbc6e 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -11,13 +11,12 @@ import ( var envCmd = &cobra.Command{ Use: "env", Short: "Print shell configuration for pv", - Long: `Print shell commands to configure PATH for pv. + Long: "Print shell commands to configure PATH for pv.", + Example: `# Add to your .zshrc or .bashrc +eval "$(pv env)" -Add this to your shell config (.zshrc, .bashrc, config.fish): - - eval "$(pv env)" - -Or run it directly to configure your current session.`, +# Configure the current session directly +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..c5f45f7 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -81,13 +81,18 @@ var installCmd = &cobra.Command{ 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() diff --git a/cmd/link.go b/cmd/link.go index 4b8cb72..3872a9b 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -20,7 +20,15 @@ var linkName string var linkCmd = &cobra.Command{ Use: "link [path]", Short: "Link a project directory", - Args: cobra.MaximumNArgs(1), + 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 { diff --git a/cmd/log.go b/cmd/log.go index f024c53..79b9613 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -23,7 +23,15 @@ var ( var logCmd = &cobra.Command{ Use: "log [site]", Short: "Tail the FrankenPHP log", - Args: cobra.MaximumNArgs(1), + 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/php_install.go b/cmd/php_install.go index cd85f75..cbe2095 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -17,7 +17,12 @@ 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), + 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 { diff --git a/cmd/php_use.go b/cmd/php_use.go index a74e394..d872830 100644 --- a/cmd/php_use.go +++ b/cmd/php_use.go @@ -14,7 +14,9 @@ import ( var phpUseCmd = &cobra.Command{ Use: "php:use ", Short: "Switch the global PHP version (e.g., pv php:use 8.4)", - Args: cobra.ExactArgs(1), + 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 == "" { diff --git a/cmd/service_add.go b/cmd/service_add.go index 42dddb5..a18f9fd 100644 --- a/cmd/service_add.go +++ b/cmd/service_add.go @@ -18,7 +18,15 @@ 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), + 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) diff --git a/cmd/unlink.go b/cmd/unlink.go index 67952ae..59a5056 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -16,7 +16,12 @@ import ( var unlinkCmd = &cobra.Command{ Use: "unlink [name]", Short: "Unlink a project", - Args: cobra.MaximumNArgs(1), + 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 { From c4738eff2d6f147844638f3c1effb24287962a63 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 23:34:21 -0500 Subject: [PATCH 03/15] =?UTF-8?q?Simplify=20error=20handling=20=E2=80=94?= =?UTF-8?q?=20let=20fang=20display=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Convert 16 files from ui.Fail/ErrAlreadyPrinted sandwich to plain fmt.Errorf returns; fang's DefaultErrorHandler renders the ERROR badge - Add custom pvErrorHandler that skips ErrAlreadyPrinted (for ui.Step failures where the spinner already displayed the error) - Change ui.Step/StepVerbose/StepProgress to return ErrAlreadyPrinted on failure since they display the error inline - Remove all cmd.SilenceUsage = true (fang sets it globally) - Remove installCmd.SilenceUsage from init - Clean up unused imports (os, ui) in simplified files --- cmd/daemon.go | 4 +--- cmd/install.go | 21 ++++++++------------- cmd/link.go | 9 +-------- cmd/php_download.go | 8 +------- cmd/php_install.go | 10 +--------- cmd/php_remove.go | 26 ++++---------------------- cmd/php_use.go | 7 +------ cmd/restart.go | 17 +++-------------- cmd/root.go | 11 +++++++++++ cmd/service_destroy.go | 7 +------ cmd/service_env.go | 6 +----- cmd/service_logs.go | 14 ++------------ cmd/service_remove.go | 7 +------ cmd/service_start.go | 7 +------ cmd/service_status.go | 6 +----- cmd/service_stop.go | 7 +------ cmd/setup.go | 1 - cmd/start.go | 6 +----- cmd/unlink.go | 12 ++---------- internal/ui/spinner.go | 6 +++--- 20 files changed, 45 insertions(+), 147 deletions(-) diff --git a/cmd/daemon.go b/cmd/daemon.go index b57f0a2..5d4e5c8 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -71,9 +71,7 @@ var daemonRestartCmd = &cobra.Command{ 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 fmt.Errorf("daemon is not running") } return ui.Step("Restarting pv daemon...", func() (string, error) { diff --git a/cmd/install.go b/cmd/install.go index c5f45f7..3382e7c 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -106,11 +106,7 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, } 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) @@ -126,7 +122,7 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, } return fmt.Sprintf("macOS %s", setup.PlatformLabel()), nil }); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 2: Create directory structure and save settings. @@ -144,7 +140,7 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, } return "Directories created", nil }); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 3: Install PHP (non-negotiable). @@ -153,24 +149,24 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, phpArgs = []string{spec.phpVersion} } if err := phpInstallCmd.RunE(phpInstallCmd, phpArgs); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 4: Install Composer (non-negotiable). if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { - return ui.ErrAlreadyPrinted + return err } // Step 5: Install Mago (opt-in via --with). if spec.mago { if err := magoInstallCmd.RunE(magoInstallCmd, nil); err != nil { - return ui.ErrAlreadyPrinted + 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. @@ -180,7 +176,7 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, 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) + ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) } } @@ -201,7 +197,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/link.go b/cmd/link.go index 3872a9b..50f98db 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -75,14 +75,7 @@ pv link --name=myapp ~/Code/myapp`, 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 diff --git a/cmd/php_download.go b/cmd/php_download.go index 180f433..766fc65 100644 --- a/cmd/php_download.go +++ b/cmd/php_download.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/ui" @@ -17,12 +16,7 @@ var phpDownloadCmd = &cobra.Command{ 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{} diff --git a/cmd/php_install.go b/cmd/php_install.go index cbe2095..7d01cbb 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -43,12 +43,7 @@ pv php:install 8.3`, } 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) { @@ -58,13 +53,10 @@ pv php:install 8.3`, 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 { diff --git a/cmd/php_remove.go b/cmd/php_remove.go index 5eb926a..38fd5d7 100644 --- a/cmd/php_remove.go +++ b/cmd/php_remove.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "regexp" "github.com/prvious/pv/internal/phpenv" @@ -18,12 +17,7 @@ var phpRemoveCmd = &cobra.Command{ 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 + return fmt.Errorf("invalid version format %q, use major.minor (e.g., 8.4)", version) } // Check if any linked projects depend on this version. @@ -36,29 +30,17 @@ var phpRemoveCmd = &cobra.Command{ 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 + return fmt.Errorf("cannot remove PHP %s, project %q depends on it", version, p.Name) } } } - fmt.Fprintln(os.Stderr) - - if err := ui.Step("Removing PHP "+version+"...", func() (string, error) { + 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 - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil + }) }, } diff --git a/cmd/php_use.go b/cmd/php_use.go index d872830..62fd52c 100644 --- a/cmd/php_use.go +++ b/cmd/php_use.go @@ -24,12 +24,7 @@ pv php:use 8.3`, } 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() diff --git a/cmd/restart.go b/cmd/restart.go index c617850..99c8372 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/server" @@ -14,8 +13,6 @@ var restartCmd = &cobra.Command{ Use: "restart", 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) @@ -23,23 +20,15 @@ var restartCmd = &cobra.Command{ // 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 18a06ef..dfc9b5c 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -2,11 +2,14 @@ package cmd import ( "context" + "errors" + "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" ) @@ -24,6 +27,7 @@ func Execute() { if err := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version), fang.WithColorSchemeFunc(pvColorScheme), + fang.WithErrorHandler(pvErrorHandler), ); err != nil { os.Exit(1) } @@ -34,3 +38,10 @@ func pvColorScheme(c lipgloss.LightDarkFunc) fang.ColorScheme { 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_destroy.go b/cmd/service_destroy.go index 29fb6d5..8b426ec 100644 --- a/cmd/service_destroy.go +++ b/cmd/service_destroy.go @@ -26,14 +26,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 diff --git a/cmd/service_env.go b/cmd/service_env.go index 134bb7c..0bf44c7 100644 --- a/cmd/service_env.go +++ b/cmd/service_env.go @@ -55,11 +55,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 diff --git a/cmd/service_logs.go b/cmd/service_logs.go index c85eea5..7e1efa4 100644 --- a/cmd/service_logs.go +++ b/cmd/service_logs.go @@ -5,7 +5,6 @@ import ( "os" "github.com/prvious/pv/internal/registry" - "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -23,20 +22,11 @@ var serviceLogsCmd = &cobra.Command{ 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) } 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 + return fmt.Errorf("service %q is not running, start it first: pv service:start %s", key, key) } // Docker SDK: ContainerLogs with Follow=true diff --git a/cmd/service_remove.go b/cmd/service_remove.go index 74b583c..c3047af 100644 --- a/cmd/service_remove.go +++ b/cmd/service_remove.go @@ -26,14 +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. diff --git a/cmd/service_start.go b/cmd/service_start.go index 00c9fa8..275c489 100644 --- a/cmd/service_start.go +++ b/cmd/service_start.go @@ -48,12 +48,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) { diff --git a/cmd/service_status.go b/cmd/service_status.go index 1215b47..016a203 100644 --- a/cmd/service_status.go +++ b/cmd/service_status.go @@ -26,11 +26,7 @@ var serviceStatusCmd = &cobra.Command{ 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) } // Parse service name and version from key. diff --git a/cmd/service_stop.go b/cmd/service_stop.go index d452c39..06ddc13 100644 --- a/cmd/service_stop.go +++ b/cmd/service_stop.go @@ -40,12 +40,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) { diff --git a/cmd/setup.go b/cmd/setup.go index f8b7959..d65f7b3 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -119,7 +119,6 @@ var setupCmd = &cobra.Command{ ) if err := form.Run(); err != nil { - cmd.SilenceUsage = true return err } diff --git a/cmd/start.go b/cmd/start.go index 1545a7e..cd2e187 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -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() diff --git a/cmd/unlink.go b/cmd/unlink.go index 59a5056..5866609 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -39,22 +39,14 @@ pv unlink`, 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 { 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) From e22deb5a3502d10e0ff1c74cf7e22a84e8b7b286 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 23:36:35 -0500 Subject: [PATCH 04/15] Remove orphaned spacing lines and unused imports - Remove bare fmt.Fprintln(os.Stderr) padding from 9 command files - Simplify daemon enable/disable to return ui.Step() directly - Clean up unused os imports after spacing removal --- cmd/colima_install.go | 4 ---- cmd/composer_install.go | 4 ---- cmd/daemon.go | 23 ++++------------------- cmd/mago_install.go | 4 ---- cmd/php_install.go | 3 --- cmd/php_use.go | 1 - cmd/start.go | 8 -------- cmd/stop.go | 6 ------ cmd/unlink.go | 1 - 9 files changed, 4 insertions(+), 50 deletions(-) diff --git a/cmd/colima_install.go b/cmd/colima_install.go index 4a693c4..5856b67 100644 --- a/cmd/colima_install.go +++ b/cmd/colima_install.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" @@ -12,8 +11,6 @@ var colimaInstallCmd = &cobra.Command{ Use: "colima:install", 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 { return err @@ -27,7 +24,6 @@ var colimaInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/composer_install.go b/cmd/composer_install.go index 598932a..c4ce1f0 100644 --- a/cmd/composer_install.go +++ b/cmd/composer_install.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" @@ -12,8 +11,6 @@ var composerInstallCmd = &cobra.Command{ Use: "composer:install", 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 { return err @@ -27,7 +24,6 @@ var composerInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/daemon.go b/cmd/daemon.go index 5d4e5c8..b0e1d47 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/ui" @@ -13,9 +12,7 @@ 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) { + return ui.Step("Installing pv daemon...", func() (string, error) { cfg := daemon.DefaultPlistConfig() cfg.RunAtLoad = true @@ -29,12 +26,7 @@ var daemonEnableCmd = &cobra.Command{ } return "Daemon installed (starts automatically on login)", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil + }) }, } @@ -42,9 +34,7 @@ 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) { + return ui.Step("Uninstalling pv daemon...", func() (string, error) { // Unload if loaded. if daemon.IsLoaded() { if err := daemon.Unload(); err != nil { @@ -57,12 +47,7 @@ var daemonDisableCmd = &cobra.Command{ } return "Daemon uninstalled", nil - }); err != nil { - return err - } - - fmt.Fprintln(os.Stderr) - return nil + }) }, } diff --git a/cmd/mago_install.go b/cmd/mago_install.go index bb88dd9..f100e23 100644 --- a/cmd/mago_install.go +++ b/cmd/mago_install.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/tools" "github.com/spf13/cobra" @@ -12,8 +11,6 @@ var magoInstallCmd = &cobra.Command{ Use: "mago:install", 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 { return err @@ -27,7 +24,6 @@ var magoInstallCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/php_install.go b/cmd/php_install.go index 7d01cbb..e7bc71e 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -3,7 +3,6 @@ package cmd import ( "fmt" "net/http" - "os" "regexp" "github.com/prvious/pv/internal/phpenv" @@ -57,7 +56,6 @@ pv php:install 8.3`, return nil } - // Download. if err := phpDownloadCmd.RunE(phpDownloadCmd, []string{version}); err != nil { return err @@ -81,7 +79,6 @@ pv php:install 8.3`, } } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/php_use.go b/cmd/php_use.go index 62fd52c..c726c6a 100644 --- a/cmd/php_use.go +++ b/cmd/php_use.go @@ -61,7 +61,6 @@ pv php:use 8.3`, ui.Subtle("Run: pv restart") } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/start.go b/cmd/start.go index cd2e187..5f7f4e9 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" @@ -46,9 +45,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 } } @@ -56,14 +53,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() @@ -96,7 +89,6 @@ func startDaemon() error { } ui.Subtle("Run pv log to view logs") - fmt.Fprintln(os.Stderr) return nil } diff --git a/cmd/stop.go b/cmd/stop.go index 943c290..2eb1591 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -16,8 +16,6 @@ var stopCmd = &cobra.Command{ Use: "stop", 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 +35,6 @@ var stopCmd = &cobra.Command{ }); err != nil { return err } - - fmt.Fprintln(os.Stderr) return nil } @@ -46,7 +42,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 +68,6 @@ var stopCmd = &cobra.Command{ return err } - fmt.Fprintln(os.Stderr) return nil }, } diff --git a/cmd/unlink.go b/cmd/unlink.go index 5866609..b3ecd38 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -83,7 +83,6 @@ pv unlink`, } } - fmt.Fprintln(os.Stderr) return nil }, } From 3024545c579bc2de94cbde1b691855722d2e5c2d Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Fri, 6 Mar 2026 23:37:51 -0500 Subject: [PATCH 05/15] =?UTF-8?q?Remove=20unused=20ui.Fatal()=20=E2=80=94?= =?UTF-8?q?=20fang=20handles=20fatal=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ui/style.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/ui/style.go b/internal/ui/style.go index 492bd11..b7d71a8 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -48,9 +48,3 @@ func Subtle(text string) { 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) -} From a2f7cc7156acb57039164d9370275e8dc8d7a9fe Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 00:01:59 -0500 Subject: [PATCH 06/15] Add comprehensive UI stack rules to CLAUDE.md Document the fang/huh/lipgloss/ui layering, error handling patterns, and hard don'ts now that the Charm v2 stack is in place. --- CLAUDE.md | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3535dec..986ec8d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,15 +59,51 @@ 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, msg)` 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. Never use raw `fmt.Print*` for status/error output in `cmd/`. Use `ui.*` helpers or return an error. +2. Never import lipgloss v1 (`github.com/charmbracelet/lipgloss`). Always use `charm.land/lipgloss/v2`. +3. Never set `SilenceUsage` or `SilenceErrors` on commands — fang owns this. +4. Never add `--version` flags — fang provides this. +5. Put usage examples in `Example:` field, not `Long:` — fang syntax-highlights `Example`. +6. 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: From 723169b338bc12a33823279845c96ca4febfc6e5 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 00:12:35 -0500 Subject: [PATCH 07/15] Strip ANSI codes in e2e assert_contains for lipgloss v2 lipgloss v2 always emits ANSI escape codes in Render(), even when stderr is piped or captured. This broke "8.4 (default)" matching in start-curl.sh because the two words use different styles with ANSI reset/re-style codes between them. --- scripts/e2e/helpers.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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. From e8d2b965567527fca2c6e2e10345a5939afeffb2 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 00:59:46 -0500 Subject: [PATCH 08/15] Address PR review: ErrAlreadyPrinted guards, go mod tidy, cleanups - Guard ErrAlreadyPrinted in update.go, install.go, service_add.go to avoid printing empty error messages after ui.Step already displayed them - Run go mod tidy to mark fang/v2 and charmtone as direct dependencies - Fix duplicate Example in env.go - Remove double blank lines in service_destroy.go, service_remove.go - Fix CLAUDE.md: Footer signature, soften fmt.Print rule to match reality --- CLAUDE.md | 4 ++-- cmd/env.go | 3 --- cmd/install.go | 3 +++ cmd/service_add.go | 9 +++++++-- cmd/service_destroy.go | 1 - cmd/service_remove.go | 1 - cmd/update.go | 19 ++++++++++++++----- go.mod | 4 ++-- go.sum | 8 ++++++++ 9 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 986ec8d..39657b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ The CLI uses a layered Charm stack: - **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, msg)` at end. +- **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`). @@ -97,7 +97,7 @@ The CLI uses a layered Charm stack: ### Hard don'ts -1. Never use raw `fmt.Print*` for status/error output in `cmd/`. Use `ui.*` helpers or return an error. +1. Prefer `ui.*` helpers or `return error` over raw `fmt.Print*` for status/error output in `cmd/`. Legacy uses remain — don't add new ones. 2. Never import lipgloss v1 (`github.com/charmbracelet/lipgloss`). Always use `charm.land/lipgloss/v2`. 3. Never set `SilenceUsage` or `SilenceErrors` on commands — fang owns this. 4. Never add `--version` flags — fang provides this. diff --git a/cmd/env.go b/cmd/env.go index 3cdbc6e..b7258f2 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -13,9 +13,6 @@ var envCmd = &cobra.Command{ 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)" - -# Configure the current session directly eval "$(pv env)"`, RunE: func(cmd *cobra.Command, args []string) error { shell := detectShell() diff --git a/cmd/install.go b/cmd/install.go index 3382e7c..b0f6868 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" "strings" @@ -176,8 +177,10 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, svcArgs = append(svcArgs, svc.version) } if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { + if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) } + } } ui.Footer(start, "https://pv.prvious.dev/docs") diff --git a/cmd/service_add.go b/cmd/service_add.go index a18f9fd..7514d4c 100644 --- a/cmd/service_add.go +++ b/cmd/service_add.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -80,7 +81,9 @@ pv service:add postgres 16`, _ = 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) { @@ -89,7 +92,9 @@ pv service:add postgres 16`, 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 } diff --git a/cmd/service_destroy.go b/cmd/service_destroy.go index 8b426ec..c60a5e9 100644 --- a/cmd/service_destroy.go +++ b/cmd/service_destroy.go @@ -29,7 +29,6 @@ var serviceDestroyCmd = &cobra.Command{ return fmt.Errorf("service %q not found", key) } - // Determine service name and version from key. svcName := key version := "latest" diff --git a/cmd/service_remove.go b/cmd/service_remove.go index c3047af..619e7a1 100644 --- a/cmd/service_remove.go +++ b/cmd/service_remove.go @@ -29,7 +29,6 @@ var serviceRemoveCmd = &cobra.Command{ return fmt.Errorf("service %q not found", key) } - if err := ui.Step(fmt.Sprintf("Removing %s...", key), func() (string, error) { // Docker SDK: stop + remove container. return fmt.Sprintf("%s removed", key), nil diff --git a/cmd/update.go b/cmd/update.go index 327d6a6..9232b85 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "net/http" "os" @@ -33,7 +34,7 @@ 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) + ui.Fail(fmt.Sprintf("pv self-update failed: %v", err)) } if reexeced { return nil // reached only if syscall.Exec failed (error already printed) @@ -44,23 +45,31 @@ var updateCmd = &cobra.Command{ 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 !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 !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 !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 !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Colima update failed: %v", err)) + } failures = append(failures, "Colima") } } diff --git a/go.mod b/go.mod index edf1aaf..d93edd2 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/prvious/pv go 1.25.0 require ( + 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 ) @@ -12,13 +14,11 @@ require ( require ( charm.land/bubbles/v2 v2.0.0 // indirect charm.land/bubbletea/v2 v2.0.0 // indirect - charm.land/fang/v2 v2.0.0-20260307033620-73847d32708b // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.3.0 // 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/charmtone v0.0.0-20260305213658-fe36e8c10185 // 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.2 // indirect diff --git a/go.sum b/go.sum index 0b793c8..2b7e5e5 100644 --- a/go.sum +++ b/go.sum @@ -49,6 +49,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJ 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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= @@ -73,6 +75,8 @@ github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe 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= @@ -80,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= @@ -98,3 +104,5 @@ 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= From 3c35d6797115736d7425650bca25c9fc2d164102 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 01:01:59 -0500 Subject: [PATCH 09/15] Clarify Hard Don'ts: separate error vs status output rules Split the vague "prefer ui.* helpers" rule into two clear rules: errors always return to fang, status output uses ui.* helpers. --- CLAUDE.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 39657b0..bb32c33 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,12 +97,13 @@ The CLI uses a layered Charm stack: ### Hard don'ts -1. Prefer `ui.*` helpers or `return error` over raw `fmt.Print*` for status/error output in `cmd/`. Legacy uses remain — don't add new ones. -2. Never import lipgloss v1 (`github.com/charmbracelet/lipgloss`). Always use `charm.land/lipgloss/v2`. -3. Never set `SilenceUsage` or `SilenceErrors` on commands — fang owns this. -4. Never add `--version` flags — fang provides this. -5. Put usage examples in `Example:` field, not `Long:` — fang syntax-highlights `Example`. -6. Don't add `fmt.Fprintln(os.Stderr)` for blank-line spacing around errors — fang handles spacing. +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 From 4fb3b87873be4f6a6f602b016302708fa12cdeb2 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 01:20:05 -0500 Subject: [PATCH 10/15] Replace raw fmt.Print error/status patterns with ui.* helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - setup.go: 6x ui.Red.Render("!") → ui.Fail() with ErrAlreadyPrinted guards - uninstall.go: 4x sub-command errors → ui.Fail() with guards, 6x post-ui.Step error prints removed (were printing empty sentinel) - link.go, unlink.go, php_use.go: ui.Red.Render("!") → ui.Fail() - update.go: add missing ErrAlreadyPrinted guard on self-update - doctor.go: all output was going to stdout instead of stderr - service_logs.go: fmt.Fprintf → ui.Subtle() --- cmd/doctor.go | 18 +++++++++--------- cmd/link.go | 2 +- cmd/php_use.go | 5 +---- cmd/service_logs.go | 4 ++-- cmd/setup.go | 21 +++++++++++++++------ cmd/uninstall.go | 29 +++++++++++++++++++---------- cmd/unlink.go | 5 +---- cmd/update.go | 4 +++- 8 files changed, 51 insertions(+), 37 deletions(-) diff --git a/cmd/doctor.go b/cmd/doctor.go index 2496df4..f1f98f8 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -55,31 +55,31 @@ var doctorCmd = &cobra.Command{ allChecks = append(allChecks, svcChecks) } - fmt.Println("pv doctor") - fmt.Println() + fmt.Fprintln(os.Stderr, "pv doctor") + fmt.Fprintln(os.Stderr) passed, failed := 0, 0 for _, section := range allChecks { - fmt.Println(section.Name) + fmt.Fprintln(os.Stderr, section.Name) for _, c := range section.Checks { if c.Status { - fmt.Printf(" ✓ %s\n", c.Name) + fmt.Fprintf(os.Stderr, " ✓ %s\n", c.Name) passed++ } else { - fmt.Printf(" ✗ %s\n", c.Name) + fmt.Fprintf(os.Stderr, " ✗ %s\n", c.Name) if c.Message != "" { - fmt.Printf(" %s\n", c.Message) + fmt.Fprintf(os.Stderr, " %s\n", c.Message) } if c.Fix != "" { - fmt.Printf(" → Run: %s\n", c.Fix) + fmt.Fprintf(os.Stderr, " → Run: %s\n", c.Fix) } failed++ } } - fmt.Println() + fmt.Fprintln(os.Stderr) } - fmt.Printf("%d passed, %d issues found\n", passed, failed) + fmt.Fprintf(os.Stderr, "%d passed, %d issues found\n", passed, failed) if failed > 0 { return fmt.Errorf("%d issues found", failed) diff --git a/cmd/link.go b/cmd/link.go index 50f98db..b82db28 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -117,7 +117,7 @@ pv link --name=myapp ~/Code/myapp`, 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/php_use.go b/cmd/php_use.go index c726c6a..c17bce4 100644 --- a/cmd/php_use.go +++ b/cmd/php_use.go @@ -49,10 +49,7 @@ pv php:use 8.3`, 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") } diff --git a/cmd/service_logs.go b/cmd/service_logs.go index 7e1efa4..89e6aa1 100644 --- a/cmd/service_logs.go +++ b/cmd/service_logs.go @@ -2,9 +2,9 @@ package cmd import ( "fmt" - "os" "github.com/prvious/pv/internal/registry" + "github.com/prvious/pv/internal/ui" "github.com/spf13/cobra" ) @@ -31,7 +31,7 @@ var serviceLogsCmd = &cobra.Command{ // 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) + ui.Subtle(fmt.Sprintf("Tailing logs for %s (container: %s)...", key, instance.ContainerID)) return nil }, diff --git a/cmd/setup.go b/cmd/setup.go index d65f7b3..f2e61cc 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "net/http" "os" @@ -162,7 +163,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,7 +179,9 @@ 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 !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Composer failed: %v", err)) + } } // Install optional tools (Colima is lazy-installed via service:add). @@ -187,13 +192,15 @@ 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 !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. @@ -203,7 +210,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,7 +229,9 @@ var setupCmd = &cobra.Command{ } 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 !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Service %s failed: %v", name, err)) + } } } } diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 399450b..463dce0 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -3,6 +3,7 @@ package cmd import ( "bufio" "encoding/json" + "errors" "fmt" "os" "os/exec" @@ -79,16 +80,24 @@ var uninstallCmd = &cobra.Command{ // 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 !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 !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 !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 !errors.Is(err, ui.ErrAlreadyPrinted) { + ui.Fail(fmt.Sprintf("Composer uninstall failed: %v", err)) + } } // Stop services. @@ -122,7 +131,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,7 +141,7 @@ 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). @@ -142,7 +151,7 @@ var uninstallCmd = &cobra.Command{ } return "", fmt.Errorf("could not remove /etc/resolver/%s — run: sudo rm -f /etc/resolver/%s", tld, tld) }); err != nil { - fmt.Fprintf(os.Stderr, " %s %v\n", ui.Red.Render("!"), err) + // Error already displayed by ui.Step } // Untrust CA certificate. @@ -171,7 +180,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 } } @@ -186,7 +195,7 @@ var uninstallCmd = &cobra.Command{ } 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. @@ -206,7 +215,7 @@ var uninstallCmd = &cobra.Command{ } 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. diff --git a/cmd/unlink.go b/cmd/unlink.go index b3ecd38..7b1d8ad 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -76,10 +76,7 @@ pv unlink`, 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)) } } diff --git a/cmd/update.go b/cmd/update.go index 9232b85..6582a6b 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -34,7 +34,9 @@ var updateCmd = &cobra.Command{ if !noSelfUpdate { reexeced, err := selfUpdate(client) if err != nil { - ui.Fail(fmt.Sprintf("pv self-update failed: %v", 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) From 87d55af62eae619e6b0dee349af76946230b6f20 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 10:46:55 -0500 Subject: [PATCH 11/15] Group help commands and replace raw I/O with huh/ui helpers - Add Cobra command groups (Core, Server, PHP, Composer, Mago, Colima, Services, Daemon) with GroupID on every command so `pv -h` renders organized, readable sections instead of a flat list. - Refactor uninstall.go: replace bufio.Scanner with huh interactive prompts, replace all raw fmt.Fprint* with ui.Subtle/ui.Success/ui.Fail per CLAUDE.md guidelines. --- cmd/colima_download.go | 3 +- cmd/colima_install.go | 3 +- cmd/colima_path.go | 3 +- cmd/colima_uninstall.go | 3 +- cmd/colima_update.go | 3 +- cmd/composer_download.go | 3 +- cmd/composer_install.go | 3 +- cmd/composer_path.go | 3 +- cmd/composer_uninstall.go | 3 +- cmd/composer_update.go | 3 +- cmd/daemon.go | 9 +++-- cmd/doctor.go | 28 +++++++------- cmd/env.go | 3 +- cmd/install.go | 3 +- cmd/link.go | 3 +- cmd/list.go | 1 + cmd/log.go | 3 +- cmd/mago_download.go | 3 +- cmd/mago_install.go | 3 +- cmd/mago_path.go | 3 +- cmd/mago_uninstall.go | 3 +- cmd/mago_update.go | 3 +- cmd/php_download.go | 3 +- cmd/php_install.go | 3 +- cmd/php_list.go | 3 +- cmd/php_path.go | 3 +- cmd/php_remove.go | 3 +- cmd/php_uninstall.go | 3 +- cmd/php_update.go | 3 +- cmd/php_use.go | 3 +- cmd/restart.go | 3 +- cmd/root.go | 13 +++++++ cmd/service_add.go | 3 +- cmd/service_destroy.go | 3 +- cmd/service_env.go | 3 +- cmd/service_list.go | 3 +- cmd/service_logs.go | 3 +- cmd/service_remove.go | 3 +- cmd/service_start.go | 3 +- cmd/service_status.go | 3 +- cmd/service_stop.go | 3 +- cmd/setup.go | 3 +- cmd/start.go | 3 +- cmd/status.go | 3 +- cmd/stop.go | 3 +- cmd/uninstall.go | 81 ++++++++++++++++++++------------------- cmd/unlink.go | 3 +- cmd/update.go | 3 +- 48 files changed, 163 insertions(+), 98 deletions(-) diff --git a/cmd/colima_download.go b/cmd/colima_download.go index 2d0a79d..7478931 100644 --- a/cmd/colima_download.go +++ b/cmd/colima_download.go @@ -10,7 +10,8 @@ import ( ) var colimaDownloadCmd = &cobra.Command{ - Use: "colima:download", + Use: "colima:download", + GroupID: "colima", Short: "Download Colima to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/cmd/colima_install.go b/cmd/colima_install.go index 5856b67..650fa9d 100644 --- a/cmd/colima_install.go +++ b/cmd/colima_install.go @@ -8,7 +8,8 @@ import ( ) var colimaInstallCmd = &cobra.Command{ - Use: "colima:install", + Use: "colima:install", + GroupID: "colima", Short: "Install or update the Colima container runtime", RunE: func(cmd *cobra.Command, args []string) error { // Download. diff --git a/cmd/colima_path.go b/cmd/colima_path.go index 2972b8d..e49a067 100644 --- a/cmd/colima_path.go +++ b/cmd/colima_path.go @@ -11,7 +11,8 @@ import ( var colimaPathRemove bool var colimaPathCmd = &cobra.Command{ - Use: "colima:path", + Use: "colima:path", + GroupID: "colima", Short: "Expose or remove Colima from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("colima") diff --git a/cmd/colima_uninstall.go b/cmd/colima_uninstall.go index 4d23fc1..8a6cf2b 100644 --- a/cmd/colima_uninstall.go +++ b/cmd/colima_uninstall.go @@ -12,7 +12,8 @@ import ( ) var colimaUninstallCmd = &cobra.Command{ - Use: "colima:uninstall", + Use: "colima:uninstall", + GroupID: "colima", Short: "Stop Colima VM and remove the binary", RunE: func(cmd *cobra.Command, args []string) error { if !colima.IsInstalled() { diff --git a/cmd/colima_update.go b/cmd/colima_update.go index f23bbdc..6a03a67 100644 --- a/cmd/colima_update.go +++ b/cmd/colima_update.go @@ -10,7 +10,8 @@ import ( ) var colimaUpdateCmd = &cobra.Command{ - Use: "colima:update", + Use: "colima:update", + GroupID: "colima", Short: "Update Colima to the latest version", RunE: func(cmd *cobra.Command, args []string) error { if !colima.IsInstalled() { diff --git a/cmd/composer_download.go b/cmd/composer_download.go index 54b8384..9bba1e1 100644 --- a/cmd/composer_download.go +++ b/cmd/composer_download.go @@ -11,7 +11,8 @@ import ( ) var composerDownloadCmd = &cobra.Command{ - Use: "composer:download", + Use: "composer:download", + GroupID: "composer", Short: "Download Composer to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/cmd/composer_install.go b/cmd/composer_install.go index c4ce1f0..b51fad4 100644 --- a/cmd/composer_install.go +++ b/cmd/composer_install.go @@ -8,7 +8,8 @@ import ( ) var composerInstallCmd = &cobra.Command{ - Use: "composer:install", + Use: "composer:install", + GroupID: "composer", Short: "Install or update Composer", RunE: func(cmd *cobra.Command, args []string) error { // Download. diff --git a/cmd/composer_path.go b/cmd/composer_path.go index 64b7441..efeb523 100644 --- a/cmd/composer_path.go +++ b/cmd/composer_path.go @@ -11,7 +11,8 @@ import ( var composerPathRemove bool var composerPathCmd = &cobra.Command{ - Use: "composer:path", + Use: "composer:path", + GroupID: "composer", Short: "Expose or remove Composer from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("composer") diff --git a/cmd/composer_uninstall.go b/cmd/composer_uninstall.go index d0e5004..0be2d29 100644 --- a/cmd/composer_uninstall.go +++ b/cmd/composer_uninstall.go @@ -11,7 +11,8 @@ import ( ) var composerUninstallCmd = &cobra.Command{ - Use: "composer:uninstall", + 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) { diff --git a/cmd/composer_update.go b/cmd/composer_update.go index f500316..26b08a5 100644 --- a/cmd/composer_update.go +++ b/cmd/composer_update.go @@ -8,7 +8,8 @@ import ( ) var composerUpdateCmd = &cobra.Command{ - Use: "composer:update", + 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). diff --git a/cmd/daemon.go b/cmd/daemon.go index b0e1d47..25f6ec9 100644 --- a/cmd/daemon.go +++ b/cmd/daemon.go @@ -9,7 +9,8 @@ import ( ) var daemonEnableCmd = &cobra.Command{ - Use: "daemon:enable", + 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) { @@ -31,7 +32,8 @@ var daemonEnableCmd = &cobra.Command{ } var daemonDisableCmd = &cobra.Command{ - Use: "daemon:disable", + 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) { @@ -52,7 +54,8 @@ var daemonDisableCmd = &cobra.Command{ } var daemonRestartCmd = &cobra.Command{ - Use: "daemon:restart", + Use: "daemon:restart", + GroupID: "daemon", Short: "Restart the pv daemon", RunE: func(cmd *cobra.Command, args []string) error { if !daemon.IsLoaded() { diff --git a/cmd/doctor.go b/cmd/doctor.go index f1f98f8..370b634 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,34 @@ var doctorCmd = &cobra.Command{ allChecks = append(allChecks, svcChecks) } - fmt.Fprintln(os.Stderr, "pv doctor") - fmt.Fprintln(os.Stderr) + fmt.Fprintf(os.Stderr, "\n %s\n", ui.Bold.Render("pv doctor")) passed, failed := 0, 0 for _, section := range allChecks { - fmt.Fprintln(os.Stderr, section.Name) + fmt.Fprintf(os.Stderr, "\n %s\n", ui.Bold.Render(section.Name)) for _, c := range section.Checks { if c.Status { - fmt.Fprintf(os.Stderr, " ✓ %s\n", c.Name) + ui.Success(c.Name) passed++ } else { - fmt.Fprintf(os.Stderr, " ✗ %s\n", c.Name) + ui.Fail(c.Name) if c.Message != "" { - fmt.Fprintf(os.Stderr, " %s\n", c.Message) + ui.FailDetail(c.Message) } if c.Fix != "" { - fmt.Fprintf(os.Stderr, " → Run: %s\n", c.Fix) + ui.FailDetail("→ Run: " + c.Fix) } failed++ } } - fmt.Fprintln(os.Stderr) } - fmt.Fprintf(os.Stderr, "%d passed, %d issues found\n", passed, failed) - + fmt.Fprintln(os.Stderr) 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 }, } diff --git a/cmd/env.go b/cmd/env.go index b7258f2..0f1db41 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -9,7 +9,8 @@ import ( ) var envCmd = &cobra.Command{ - Use: "env", + 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 diff --git a/cmd/install.go b/cmd/install.go index b0f6868..73b6af1 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -76,7 +76,8 @@ func parseWith(raw string) (withSpec, error) { } var installCmd = &cobra.Command{ - Use: "install", + 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 diff --git a/cmd/link.go b/cmd/link.go index b82db28..f28c74e 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -18,7 +18,8 @@ import ( var linkName string var linkCmd = &cobra.Command{ - Use: "link [path]", + Use: "link [path]", + GroupID: "core", Short: "Link a project directory", Example: `# Link the current directory pv link 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 79b9613..82a056c 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -21,7 +21,8 @@ var ( ) var logCmd = &cobra.Command{ - Use: "log [site]", + Use: "log [site]", + GroupID: "core", Short: "Tail the FrankenPHP log", Example: `# Tail all logs pv log diff --git a/cmd/mago_download.go b/cmd/mago_download.go index f7fe9e8..0aa40de 100644 --- a/cmd/mago_download.go +++ b/cmd/mago_download.go @@ -11,7 +11,8 @@ import ( ) var magoDownloadCmd = &cobra.Command{ - Use: "mago:download", + Use: "mago:download", + GroupID: "mago", Short: "Download Mago to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/cmd/mago_install.go b/cmd/mago_install.go index f100e23..c45f372 100644 --- a/cmd/mago_install.go +++ b/cmd/mago_install.go @@ -8,7 +8,8 @@ import ( ) var magoInstallCmd = &cobra.Command{ - Use: "mago:install", + Use: "mago:install", + GroupID: "mago", Short: "Install or update Mago", RunE: func(cmd *cobra.Command, args []string) error { // Download. diff --git a/cmd/mago_path.go b/cmd/mago_path.go index 0eca6ca..a40b94b 100644 --- a/cmd/mago_path.go +++ b/cmd/mago_path.go @@ -11,7 +11,8 @@ import ( var magoPathRemove bool var magoPathCmd = &cobra.Command{ - Use: "mago:path", + Use: "mago:path", + GroupID: "mago", Short: "Expose or remove Mago from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("mago") diff --git a/cmd/mago_uninstall.go b/cmd/mago_uninstall.go index 71942c3..791f3d9 100644 --- a/cmd/mago_uninstall.go +++ b/cmd/mago_uninstall.go @@ -11,7 +11,8 @@ import ( ) var magoUninstallCmd = &cobra.Command{ - Use: "mago:uninstall", + 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) { diff --git a/cmd/mago_update.go b/cmd/mago_update.go index 6e03b9a..759ed60 100644 --- a/cmd/mago_update.go +++ b/cmd/mago_update.go @@ -11,7 +11,8 @@ import ( ) var magoUpdateCmd = &cobra.Command{ - Use: "mago:update", + Use: "mago:update", + GroupID: "mago", Short: "Update Mago to the latest version", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/cmd/php_download.go b/cmd/php_download.go index 766fc65..19487bd 100644 --- a/cmd/php_download.go +++ b/cmd/php_download.go @@ -10,7 +10,8 @@ import ( ) var phpDownloadCmd = &cobra.Command{ - Use: "php:download ", + Use: "php:download ", + GroupID: "php", Short: "Download PHP + FrankenPHP to internal storage", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/php_install.go b/cmd/php_install.go index e7bc71e..8b0fa06 100644 --- a/cmd/php_install.go +++ b/cmd/php_install.go @@ -14,7 +14,8 @@ import ( var validPHPVersion = regexp.MustCompile(`^\d+\.\d+$`) var phpInstallCmd = &cobra.Command{ - Use: "php:install [version]", + 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 diff --git a/cmd/php_list.go b/cmd/php_list.go index 27e5ef5..c79840d 100644 --- a/cmd/php_list.go +++ b/cmd/php_list.go @@ -12,7 +12,8 @@ import ( ) var phpListCmd = &cobra.Command{ - Use: "php:list", + Use: "php:list", + GroupID: "php", Short: "List installed PHP versions", RunE: func(cmd *cobra.Command, args []string) error { versions, err := phpenv.InstalledVersions() diff --git a/cmd/php_path.go b/cmd/php_path.go index dc7a810..3fd7c7e 100644 --- a/cmd/php_path.go +++ b/cmd/php_path.go @@ -11,7 +11,8 @@ import ( var phpPathRemove bool var phpPathCmd = &cobra.Command{ - Use: "php:path", + 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") diff --git a/cmd/php_remove.go b/cmd/php_remove.go index 38fd5d7..e3030ad 100644 --- a/cmd/php_remove.go +++ b/cmd/php_remove.go @@ -11,7 +11,8 @@ import ( ) var phpRemoveCmd = &cobra.Command{ - Use: "php:remove ", + Use: "php:remove ", + GroupID: "php", Short: "Remove an installed PHP version", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/php_uninstall.go b/cmd/php_uninstall.go index ca1dd98..2067f98 100644 --- a/cmd/php_uninstall.go +++ b/cmd/php_uninstall.go @@ -11,7 +11,8 @@ import ( ) var phpUninstallCmd = &cobra.Command{ - Use: "php:uninstall", + 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) { diff --git a/cmd/php_update.go b/cmd/php_update.go index e88b74c..fc49caa 100644 --- a/cmd/php_update.go +++ b/cmd/php_update.go @@ -11,7 +11,8 @@ import ( ) var phpUpdateCmd = &cobra.Command{ - Use: "php:update", + 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{} diff --git a/cmd/php_use.go b/cmd/php_use.go index c17bce4..32577c4 100644 --- a/cmd/php_use.go +++ b/cmd/php_use.go @@ -12,7 +12,8 @@ import ( ) var phpUseCmd = &cobra.Command{ - Use: "php:use ", + Use: "php:use ", + 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`, diff --git a/cmd/restart.go b/cmd/restart.go index 99c8372..847241d 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -10,7 +10,8 @@ import ( ) var restartCmd = &cobra.Command{ - Use: "restart", + Use: "restart", + GroupID: "server", Short: "Restart or reload the pv server", RunE: func(cmd *cobra.Command, args []string) error { // Daemon mode — delegate to daemon:restart. diff --git a/cmd/root.go b/cmd/root.go index dfc9b5c..07e3084 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,19 @@ var rootCmd = &cobra.Command{ 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 := fang.Execute(context.Background(), rootCmd, fang.WithVersion(version), diff --git a/cmd/service_add.go b/cmd/service_add.go index 7514d4c..93ad678 100644 --- a/cmd/service_add.go +++ b/cmd/service_add.go @@ -16,7 +16,8 @@ import ( ) var serviceAddCmd = &cobra.Command{ - Use: "service:add [version]", + 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 diff --git a/cmd/service_destroy.go b/cmd/service_destroy.go index c60a5e9..95f725b 100644 --- a/cmd/service_destroy.go +++ b/cmd/service_destroy.go @@ -13,7 +13,8 @@ import ( ) var serviceDestroyCmd = &cobra.Command{ - Use: "service:destroy ", + 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 { diff --git a/cmd/service_env.go b/cmd/service_env.go index 0bf44c7..f6d41df 100644 --- a/cmd/service_env.go +++ b/cmd/service_env.go @@ -13,7 +13,8 @@ import ( ) var serviceEnvCmd = &cobra.Command{ - Use: "service:env [service]", + 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 { diff --git a/cmd/service_list.go b/cmd/service_list.go index 985d959..2bcecc1 100644 --- a/cmd/service_list.go +++ b/cmd/service_list.go @@ -11,7 +11,8 @@ import ( ) var serviceListCmd = &cobra.Command{ - Use: "service:list", + Use: "service:list", + GroupID: "service", Short: "List all services", RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() diff --git a/cmd/service_logs.go b/cmd/service_logs.go index 89e6aa1..e0fc949 100644 --- a/cmd/service_logs.go +++ b/cmd/service_logs.go @@ -9,7 +9,8 @@ import ( ) var serviceLogsCmd = &cobra.Command{ - Use: "service:logs ", + Use: "service:logs ", + GroupID: "service", Short: "Tail container logs for a service", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/service_remove.go b/cmd/service_remove.go index 619e7a1..bc5dd95 100644 --- a/cmd/service_remove.go +++ b/cmd/service_remove.go @@ -13,7 +13,8 @@ import ( ) var serviceRemoveCmd = &cobra.Command{ - Use: "service:remove ", + 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 { diff --git a/cmd/service_start.go b/cmd/service_start.go index 275c489..2e9d21d 100644 --- a/cmd/service_start.go +++ b/cmd/service_start.go @@ -11,7 +11,8 @@ import ( ) var serviceStartCmd = &cobra.Command{ - Use: "service:start [service]", + 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 { diff --git a/cmd/service_status.go b/cmd/service_status.go index 016a203..4a20140 100644 --- a/cmd/service_status.go +++ b/cmd/service_status.go @@ -13,7 +13,8 @@ import ( ) var serviceStatusCmd = &cobra.Command{ - Use: "service:status ", + Use: "service:status ", + GroupID: "service", Short: "Show detailed status for a service", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/service_stop.go b/cmd/service_stop.go index 06ddc13..522cc71 100644 --- a/cmd/service_stop.go +++ b/cmd/service_stop.go @@ -10,7 +10,8 @@ import ( ) var serviceStopCmd = &cobra.Command{ - Use: "service:stop [service]", + 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 { diff --git a/cmd/setup.go b/cmd/setup.go index f2e61cc..5fda134 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -18,7 +18,8 @@ import ( ) var setupCmd = &cobra.Command{ - Use: "setup", + 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() diff --git a/cmd/start.go b/cmd/start.go index 5f7f4e9..64f05e7 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -17,7 +17,8 @@ var ( ) var startCmd = &cobra.Command{ - Use: "start", + Use: "start", + GroupID: "server", Short: "Start the pv server (DNS + FrankenPHP)", RunE: func(cmd *cobra.Command, args []string) error { if startBackground { diff --git a/cmd/status.go b/cmd/status.go index 8fd5281..0457604 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -15,7 +15,8 @@ import ( ) var statusCmd = &cobra.Command{ - Use: "status", + Use: "status", + GroupID: "core", Short: "Show pv server status", RunE: func(cmd *cobra.Command, args []string) error { settings, err := config.LoadSettings() diff --git a/cmd/stop.go b/cmd/stop.go index 2eb1591..81dcb56 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -13,7 +13,8 @@ import ( ) var stopCmd = &cobra.Command{ - Use: "stop", + Use: "stop", + GroupID: "server", Short: "Stop the pv server", RunE: func(cmd *cobra.Command, args []string) error { // Check daemon mode first. diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 463dce0..04ad86b 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -1,17 +1,16 @@ package cmd import ( - "bufio" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" - "strings" "syscall" "time" + "charm.land/huh/v2" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/daemon" "github.com/prvious/pv/internal/registry" @@ -22,48 +21,55 @@ import ( ) var uninstallCmd = &cobra.Command{ - Use: "uninstall", + 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.") + 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" { + 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). @@ -219,7 +225,6 @@ var uninstallCmd = &cobra.Command{ } // Report scattered .pv-php files. - fmt.Fprintln(os.Stderr) var found []string for _, p := range projectPaths { pvPhpPath := filepath.Join(p, ".pv-php") @@ -228,12 +233,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. @@ -241,13 +245,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 }, diff --git a/cmd/unlink.go b/cmd/unlink.go index 7b1d8ad..40a293f 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -14,7 +14,8 @@ import ( ) var unlinkCmd = &cobra.Command{ - Use: "unlink [name]", + Use: "unlink [name]", + GroupID: "core", Short: "Unlink a project", Example: `# Unlink by name pv unlink myapp diff --git a/cmd/update.go b/cmd/update.go index 6582a6b..3c4fb76 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -21,7 +21,8 @@ var ( ) var updateCmd = &cobra.Command{ - Use: "update", + 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() From f961f8b29ded30649d7f4d7b234de2b23349c585 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 11:46:25 -0500 Subject: [PATCH 12/15] Move tool/service/daemon commands into internal/commands// subpackages Restructure flat cmd/ files into grouped subpackages under internal/commands/{php,mago,composer,colima,service,daemon}/. Each group exports Register() for command registration and Run*() wrappers for orchestrator access. Thin shims in cmd/ call Register(). Also fix UI guideline violations in service status/remove commands: replace raw fmt.Fprintf with ui.Table, remove blank-line spacers. --- cmd/colima.go | 7 ++ cmd/composer.go | 7 ++ cmd/daemon.go | 78 ------------------- cmd/daemon_cmds.go | 7 ++ cmd/install.go | 12 ++- cmd/link_services.go | 4 + cmd/mago.go | 7 ++ cmd/php.go | 7 ++ cmd/restart.go | 3 +- cmd/service.go | 7 ++ cmd/setup.go | 9 ++- cmd/uninstall.go | 12 ++- cmd/update.go | 12 ++- .../commands/colima/download.go | 14 ++-- .../commands/colima/install.go | 12 +-- .../commands/colima/path.go | 13 ++-- internal/commands/colima/register.go | 23 ++++++ .../commands/colima/uninstall.go | 20 ++--- .../commands/colima/update.go | 16 ++-- .../commands/composer/download.go | 8 +- .../commands/composer/install.go | 10 +-- .../commands/composer/path.go | 11 ++- internal/commands/composer/register.go | 23 ++++++ .../commands/composer/uninstall.go | 8 +- .../commands/composer/update.go | 10 +-- internal/commands/daemon/disable.go | 31 ++++++++ internal/commands/daemon/enable.go | 32 ++++++++ internal/commands/daemon/register.go | 13 ++++ internal/commands/daemon/restart.go | 27 +++++++ .../commands/mago/download.go | 8 +- .../commands/mago/install.go | 10 +-- .../commands/mago/path.go | 11 ++- internal/commands/mago/register.go | 27 +++++++ .../commands/mago/uninstall.go | 8 +- .../commands/mago/update.go | 10 +-- .../commands/php/download.go | 8 +- .../commands/php/install.go | 10 +-- .../commands/php/list.go | 8 +- .../commands/php/path.go | 11 ++- internal/commands/php/register.go | 26 +++++++ .../commands/php/remove.go | 8 +- .../commands/php/uninstall.go | 8 +- .../commands/php/update.go | 8 +- .../commands/php/use.go | 8 +- .../commands/service/add.go | 11 +-- .../commands/service/destroy.go | 8 +- .../commands/service/env.go | 8 +- .../commands/service/list.go | 8 +- .../commands/service/logs.go | 8 +- internal/commands/service/register.go | 19 +++++ .../commands/service/remove.go | 11 +-- .../commands/service/start.go | 8 +- .../commands/service/status.go | 29 +++---- .../commands/service/stop.go | 8 +- 54 files changed, 408 insertions(+), 322 deletions(-) create mode 100644 cmd/colima.go create mode 100644 cmd/composer.go delete mode 100644 cmd/daemon.go create mode 100644 cmd/daemon_cmds.go create mode 100644 cmd/mago.go create mode 100644 cmd/php.go create mode 100644 cmd/service.go rename cmd/colima_download.go => internal/commands/colima/download.go (63%) rename cmd/colima_install.go => internal/commands/colima/install.go (65%) rename cmd/colima_path.go => internal/commands/colima/path.go (66%) create mode 100644 internal/commands/colima/register.go rename cmd/colima_uninstall.go => internal/commands/colima/uninstall.go (71%) rename cmd/colima_update.go => internal/commands/colima/update.go (66%) rename cmd/composer_download.go => internal/commands/composer/download.go (91%) rename cmd/composer_install.go => internal/commands/composer/install.go (71%) rename cmd/composer_path.go => internal/commands/composer/path.go (71%) create mode 100644 internal/commands/composer/register.go rename cmd/composer_uninstall.go => internal/commands/composer/uninstall.go (87%) rename cmd/composer_update.go => internal/commands/composer/update.go (74%) create mode 100644 internal/commands/daemon/disable.go create mode 100644 internal/commands/daemon/enable.go create mode 100644 internal/commands/daemon/register.go create mode 100644 internal/commands/daemon/restart.go rename cmd/mago_download.go => internal/commands/mago/download.go (91%) rename cmd/mago_install.go => internal/commands/mago/install.go (72%) rename cmd/mago_path.go => internal/commands/mago/path.go (72%) create mode 100644 internal/commands/mago/register.go rename cmd/mago_uninstall.go => internal/commands/mago/uninstall.go (86%) rename cmd/mago_update.go => internal/commands/mago/update.go (85%) rename cmd/php_download.go => internal/commands/php/download.go (88%) rename cmd/php_install.go => internal/commands/php/install.go (91%) rename cmd/php_list.go => internal/commands/php/list.go (94%) rename cmd/php_path.go => internal/commands/php/path.go (79%) create mode 100644 internal/commands/php/register.go rename cmd/php_remove.go => internal/commands/php/remove.go (91%) rename cmd/php_uninstall.go => internal/commands/php/uninstall.go (87%) rename cmd/php_update.go => internal/commands/php/update.go (92%) rename cmd/php_use.go => internal/commands/php/use.go (94%) rename cmd/service_add.go => internal/commands/service/add.go (96%) rename cmd/service_destroy.go => internal/commands/service/destroy.go (94%) rename cmd/service_env.go => internal/commands/service/env.go (95%) rename cmd/service_list.go => internal/commands/service/list.go (92%) rename cmd/service_logs.go => internal/commands/service/logs.go (89%) create mode 100644 internal/commands/service/register.go rename cmd/service_remove.go => internal/commands/service/remove.go (90%) rename cmd/service_start.go => internal/commands/service/start.go (93%) rename cmd/service_status.go => internal/commands/service/status.go (59%) rename cmd/service_stop.go => internal/commands/service/stop.go (92%) 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 25f6ec9..0000000 --- a/cmd/daemon.go +++ /dev/null @@ -1,78 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/prvious/pv/internal/daemon" - "github.com/prvious/pv/internal/ui" - "github.com/spf13/cobra" -) - -var daemonEnableCmd = &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 := 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 - }) - }, -} - -var daemonDisableCmd = &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 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 - }) - }, -} - -var daemonRestartCmd = &cobra.Command{ - Use: "daemon:restart", - GroupID: "daemon", - Short: "Restart the pv daemon", - RunE: func(cmd *cobra.Command, args []string) error { - if !daemon.IsLoaded() { - return fmt.Errorf("daemon is not running") - } - - 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/install.go b/cmd/install.go index 73b6af1..4f524b1 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -7,6 +7,10 @@ import ( "strings" "time" + "github.com/prvious/pv/internal/commands/mago" + "github.com/prvious/pv/internal/commands/composer" + "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" @@ -150,18 +154,18 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, if spec.phpVersion != "" { phpArgs = []string{spec.phpVersion} } - if err := phpInstallCmd.RunE(phpInstallCmd, phpArgs); err != nil { + if err := php.RunInstall(phpArgs); err != nil { return err } // Step 4: Install Composer (non-negotiable). - if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { + 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 { + if err := mago.RunInstall(); err != nil { return err } } @@ -177,7 +181,7 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, if svc.version != "" { svcArgs = append(svcArgs, svc.version) } - if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { + if err := service.RunAdd(svcArgs); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) } diff --git a/cmd/link_services.go b/cmd/link_services.go index 5548785..dd0637f 100644 --- a/cmd/link_services.go +++ b/cmd/link_services.go @@ -167,3 +167,7 @@ func containsStr(slice []string, s string) bool { } return false } + +func sanitizeProjectName(name string) string { + return strings.ReplaceAll(name, "-", "_") +} 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/restart.go b/cmd/restart.go index 847241d..2815784 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" + 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" @@ -16,7 +17,7 @@ var restartCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { // Daemon mode — delegate to daemon:restart. if daemon.IsLoaded() { - return daemonRestartCmd.RunE(daemonRestartCmd, nil) + return daemoncmds.RunRestart() } // Foreground mode — reload config via admin API. 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/setup.go b/cmd/setup.go index 5fda134..76b950e 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -8,6 +8,9 @@ import ( "time" "charm.land/huh/v2" + "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/binaries" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" @@ -179,7 +182,7 @@ var setupCmd = &cobra.Command{ } // Install Composer (non-negotiable). - if err := composerInstallCmd.RunE(composerInstallCmd, nil); err != nil { + if err := composer.RunInstall(); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Composer failed: %v", err)) } @@ -192,7 +195,7 @@ var setupCmd = &cobra.Command{ } if toolSet["mago"] { - if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + if err := mago.RunDownload(); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Mago failed: %v", err)) } @@ -229,7 +232,7 @@ var setupCmd = &cobra.Command{ continue } svcArgs := []string{name, svc.DefaultVersion()} - if err := serviceAddCmd.RunE(serviceAddCmd, svcArgs); err != nil { + 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/uninstall.go b/cmd/uninstall.go index 04ad86b..b40012c 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -11,6 +11,10 @@ import ( "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" @@ -85,22 +89,22 @@ var uninstallCmd = &cobra.Command{ tld := settings.TLD // Uninstall tools (each cleans up its own binary + PATH entry). - if err := colimaUninstallCmd.RunE(colimaUninstallCmd, nil); err != nil { + 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 { + 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 { + 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 { + if err := composer.RunUninstall(); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Composer uninstall failed: %v", err)) } diff --git a/cmd/update.go b/cmd/update.go index 3c4fb76..60ccebf 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -9,6 +9,10 @@ import ( "syscall" "time" + 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/colima" "github.com/prvious/pv/internal/selfupdate" "github.com/prvious/pv/internal/ui" @@ -47,21 +51,21 @@ var updateCmd = &cobra.Command{ // Step 2: Update tools. var failures []string - if err := phpUpdateCmd.RunE(phpUpdateCmd, nil); err != nil { + 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 { + 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 { + if err := composer.RunUpdate(); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Composer update failed: %v", err)) } @@ -69,7 +73,7 @@ var updateCmd = &cobra.Command{ } if colima.IsInstalled() { - if err := colimaUpdateCmd.RunE(colimaUpdateCmd, nil); err != nil { + if err := colimacmd.RunUpdate(); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { ui.Fail(fmt.Sprintf("Colima update failed: %v", err)) } diff --git a/cmd/colima_download.go b/internal/commands/colima/download.go similarity index 63% rename from cmd/colima_download.go rename to internal/commands/colima/download.go index 7478931..29a6956 100644 --- a/cmd/colima_download.go +++ b/internal/commands/colima/download.go @@ -1,30 +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{ +var downloadCmd = &cobra.Command{ Use: "colima:download", GroupID: "colima", - Short: "Download Colima to internal storage", + 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 65% rename from cmd/colima_install.go rename to internal/commands/colima/install.go index 650fa9d..a5d3b5e 100644 --- a/cmd/colima_install.go +++ b/internal/commands/colima/install.go @@ -1,4 +1,4 @@ -package cmd +package colima import ( "fmt" @@ -7,13 +7,13 @@ import ( "github.com/spf13/cobra" ) -var colimaInstallCmd = &cobra.Command{ +var installCmd = &cobra.Command{ Use: "colima:install", GroupID: "colima", - Short: "Install or update the Colima container runtime", + Short: "Install or update the Colima container runtime", RunE: func(cmd *cobra.Command, args []string) error { // Download. - if err := colimaDownloadCmd.RunE(colimaDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -28,7 +28,3 @@ var colimaInstallCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(colimaInstallCmd) -} diff --git a/cmd/colima_path.go b/internal/commands/colima/path.go similarity index 66% rename from cmd/colima_path.go rename to internal/commands/colima/path.go index e49a067..469615b 100644 --- a/cmd/colima_path.go +++ b/internal/commands/colima/path.go @@ -1,4 +1,4 @@ -package cmd +package colima import ( "fmt" @@ -8,16 +8,16 @@ import ( "github.com/spf13/cobra" ) -var colimaPathRemove bool +var pathRemove bool -var colimaPathCmd = &cobra.Command{ +var pathCmd = &cobra.Command{ Use: "colima:path", GroupID: "colima", - Short: "Expose or remove Colima from PATH", + 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 } @@ -34,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/cmd/colima_uninstall.go b/internal/commands/colima/uninstall.go similarity index 71% rename from cmd/colima_uninstall.go rename to internal/commands/colima/uninstall.go index 8a6cf2b..9bd44b9 100644 --- a/cmd/colima_uninstall.go +++ b/internal/commands/colima/uninstall.go @@ -1,32 +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{ +var uninstallCmd = &cobra.Command{ Use: "colima:uninstall", GroupID: "colima", - Short: "Stop Colima VM and remove the binary", + 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) } } @@ -43,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 66% rename from cmd/colima_update.go rename to internal/commands/colima/update.go index 6a03a67..64e0424 100644 --- a/cmd/colima_update.go +++ b/internal/commands/colima/update.go @@ -1,26 +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{ +var updateCmd = &cobra.Command{ Use: "colima:update", GroupID: "colima", - Short: "Update Colima to the latest version", + 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 } @@ -35,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 91% rename from cmd/composer_download.go rename to internal/commands/composer/download.go index 9bba1e1..bccb5bb 100644 --- a/cmd/composer_download.go +++ b/internal/commands/composer/download.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var composerDownloadCmd = &cobra.Command{ +var downloadCmd = &cobra.Command{ Use: "composer:download", GroupID: "composer", Short: "Download Composer to internal storage", @@ -45,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 71% rename from cmd/composer_install.go rename to internal/commands/composer/install.go index b51fad4..bf9c3b9 100644 --- a/cmd/composer_install.go +++ b/internal/commands/composer/install.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -7,13 +7,13 @@ import ( "github.com/spf13/cobra" ) -var composerInstallCmd = &cobra.Command{ +var installCmd = &cobra.Command{ Use: "composer:install", GroupID: "composer", Short: "Install or update Composer", RunE: func(cmd *cobra.Command, args []string) error { // Download. - if err := composerDownloadCmd.RunE(composerDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -28,7 +28,3 @@ var composerInstallCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(composerInstallCmd) -} diff --git a/cmd/composer_path.go b/internal/commands/composer/path.go similarity index 71% rename from cmd/composer_path.go rename to internal/commands/composer/path.go index efeb523..953fa04 100644 --- a/cmd/composer_path.go +++ b/internal/commands/composer/path.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -8,16 +8,16 @@ import ( "github.com/spf13/cobra" ) -var composerPathRemove bool +var pathRemove bool -var composerPathCmd = &cobra.Command{ +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 } @@ -34,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/cmd/composer_uninstall.go b/internal/commands/composer/uninstall.go similarity index 87% rename from cmd/composer_uninstall.go rename to internal/commands/composer/uninstall.go index 0be2d29..dd089d2 100644 --- a/cmd/composer_uninstall.go +++ b/internal/commands/composer/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var composerUninstallCmd = &cobra.Command{ +var uninstallCmd = &cobra.Command{ Use: "composer:uninstall", GroupID: "composer", Short: "Remove Composer PHAR, PATH entry, and global packages", @@ -32,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 74% rename from cmd/composer_update.go rename to internal/commands/composer/update.go index 26b08a5..cda0a5a 100644 --- a/cmd/composer_update.go +++ b/internal/commands/composer/update.go @@ -1,4 +1,4 @@ -package cmd +package composer import ( "fmt" @@ -7,13 +7,13 @@ import ( "github.com/spf13/cobra" ) -var composerUpdateCmd = &cobra.Command{ +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 } @@ -28,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/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 91% rename from cmd/mago_download.go rename to internal/commands/mago/download.go index 0aa40de..2ee873c 100644 --- a/cmd/mago_download.go +++ b/internal/commands/mago/download.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var magoDownloadCmd = &cobra.Command{ +var downloadCmd = &cobra.Command{ Use: "mago:download", GroupID: "mago", Short: "Download Mago to internal storage", @@ -45,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 72% rename from cmd/mago_install.go rename to internal/commands/mago/install.go index c45f372..e43e5e6 100644 --- a/cmd/mago_install.go +++ b/internal/commands/mago/install.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -7,13 +7,13 @@ import ( "github.com/spf13/cobra" ) -var magoInstallCmd = &cobra.Command{ +var installCmd = &cobra.Command{ Use: "mago:install", GroupID: "mago", Short: "Install or update Mago", RunE: func(cmd *cobra.Command, args []string) error { // Download. - if err := magoDownloadCmd.RunE(magoDownloadCmd, nil); err != nil { + if err := downloadCmd.RunE(downloadCmd, nil); err != nil { return err } @@ -28,7 +28,3 @@ var magoInstallCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(magoInstallCmd) -} diff --git a/cmd/mago_path.go b/internal/commands/mago/path.go similarity index 72% rename from cmd/mago_path.go rename to internal/commands/mago/path.go index a40b94b..92ce342 100644 --- a/cmd/mago_path.go +++ b/internal/commands/mago/path.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -8,16 +8,16 @@ import ( "github.com/spf13/cobra" ) -var magoPathRemove bool +var pathRemove bool -var magoPathCmd = &cobra.Command{ +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 } @@ -34,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/cmd/mago_uninstall.go b/internal/commands/mago/uninstall.go similarity index 86% rename from cmd/mago_uninstall.go rename to internal/commands/mago/uninstall.go index 791f3d9..7a559aa 100644 --- a/cmd/mago_uninstall.go +++ b/internal/commands/mago/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var magoUninstallCmd = &cobra.Command{ +var uninstallCmd = &cobra.Command{ Use: "mago:uninstall", GroupID: "mago", Short: "Remove Mago binary and PATH entry", @@ -28,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 85% rename from cmd/mago_update.go rename to internal/commands/mago/update.go index 759ed60..196ac40 100644 --- a/cmd/mago_update.go +++ b/internal/commands/mago/update.go @@ -1,4 +1,4 @@ -package cmd +package mago import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var magoUpdateCmd = &cobra.Command{ +var updateCmd = &cobra.Command{ Use: "mago:update", GroupID: "mago", Short: "Update Mago to the latest version", @@ -33,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 } @@ -48,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 88% rename from cmd/php_download.go rename to internal/commands/php/download.go index 19487bd..4109ac0 100644 --- a/cmd/php_download.go +++ b/internal/commands/php/download.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -var phpDownloadCmd = &cobra.Command{ +var downloadCmd = &cobra.Command{ Use: "php:download ", GroupID: "php", Short: "Download PHP + FrankenPHP to internal storage", @@ -29,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 91% rename from cmd/php_install.go rename to internal/commands/php/install.go index 8b0fa06..beeb4ca 100644 --- a/cmd/php_install.go +++ b/internal/commands/php/install.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -13,7 +13,7 @@ import ( var validPHPVersion = regexp.MustCompile(`^\d+\.\d+$`) -var phpInstallCmd = &cobra.Command{ +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.", @@ -58,7 +58,7 @@ pv php:install 8.3`, } // Download. - if err := phpDownloadCmd.RunE(phpDownloadCmd, []string{version}); err != nil { + if err := downloadCmd.RunE(downloadCmd, []string{version}); err != nil { return err } @@ -83,7 +83,3 @@ pv php:install 8.3`, return nil }, } - -func init() { - rootCmd.AddCommand(phpInstallCmd) -} diff --git a/cmd/php_list.go b/internal/commands/php/list.go similarity index 94% rename from cmd/php_list.go rename to internal/commands/php/list.go index c79840d..afb6ecf 100644 --- a/cmd/php_list.go +++ b/internal/commands/php/list.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var phpListCmd = &cobra.Command{ +var listCmd = &cobra.Command{ Use: "php:list", GroupID: "php", Short: "List installed PHP versions", @@ -71,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 79% rename from cmd/php_path.go rename to internal/commands/php/path.go index 3fd7c7e..ba13efc 100644 --- a/cmd/php_path.go +++ b/internal/commands/php/path.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -8,9 +8,9 @@ import ( "github.com/spf13/cobra" ) -var phpPathRemove bool +var pathRemove bool -var phpPathCmd = &cobra.Command{ +var pathCmd = &cobra.Command{ Use: "php:path", GroupID: "php", Short: "Expose or remove PHP and FrankenPHP from PATH", @@ -18,7 +18,7 @@ var phpPathCmd = &cobra.Command{ php := tools.MustGet("php") fp := tools.MustGet("frankenphp") - if phpPathRemove { + if pathRemove { if err := tools.Unexpose(php); err != nil { return err } @@ -41,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/cmd/php_remove.go b/internal/commands/php/remove.go similarity index 91% rename from cmd/php_remove.go rename to internal/commands/php/remove.go index e3030ad..2b96949 100644 --- a/cmd/php_remove.go +++ b/internal/commands/php/remove.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var phpRemoveCmd = &cobra.Command{ +var removeCmd = &cobra.Command{ Use: "php:remove ", GroupID: "php", Short: "Remove an installed PHP version", @@ -44,7 +44,3 @@ var phpRemoveCmd = &cobra.Command{ }) }, } - -func init() { - rootCmd.AddCommand(phpRemoveCmd) -} diff --git a/cmd/php_uninstall.go b/internal/commands/php/uninstall.go similarity index 87% rename from cmd/php_uninstall.go rename to internal/commands/php/uninstall.go index 2067f98..ca2b611 100644 --- a/cmd/php_uninstall.go +++ b/internal/commands/php/uninstall.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var phpUninstallCmd = &cobra.Command{ +var uninstallCmd = &cobra.Command{ Use: "php:uninstall", GroupID: "php", Short: "Remove all PHP versions and PATH entries", @@ -30,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 92% rename from cmd/php_update.go rename to internal/commands/php/update.go index fc49caa..7b1636e 100644 --- a/cmd/php_update.go +++ b/internal/commands/php/update.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var phpUpdateCmd = &cobra.Command{ +var updateCmd = &cobra.Command{ Use: "php:update", GroupID: "php", Short: "Re-download all installed PHP versions with the latest builds", @@ -51,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 94% rename from cmd/php_use.go rename to internal/commands/php/use.go index 32577c4..462c5cc 100644 --- a/cmd/php_use.go +++ b/internal/commands/php/use.go @@ -1,4 +1,4 @@ -package cmd +package php import ( "fmt" @@ -11,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var phpUseCmd = &cobra.Command{ +var useCmd = &cobra.Command{ Use: "php:use ", GroupID: "php", Short: "Switch the global PHP version (e.g., pv php:use 8.4)", @@ -62,7 +62,3 @@ pv php:use 8.3`, return nil }, } - -func init() { - rootCmd.AddCommand(phpUseCmd) -} diff --git a/cmd/service_add.go b/internal/commands/service/add.go similarity index 96% rename from cmd/service_add.go rename to internal/commands/service/add.go index 93ad678..74f8e7b 100644 --- a/cmd/service_add.go +++ b/internal/commands/service/add.go @@ -1,10 +1,11 @@ -package cmd +package service import ( "errors" "fmt" "os" + colimacmds "github.com/prvious/pv/internal/commands/colima" "github.com/prvious/pv/internal/caddy" "github.com/prvious/pv/internal/colima" "github.com/prvious/pv/internal/config" @@ -15,7 +16,7 @@ import ( "github.com/spf13/cobra" ) -var serviceAddCmd = &cobra.Command{ +var addCmd = &cobra.Command{ Use: "service:add [version]", GroupID: "service", Short: "Add and start a service", @@ -63,7 +64,7 @@ pv service:add postgres 16`, // 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) } } @@ -162,7 +163,3 @@ pv service:add postgres 16`, return nil }, } - -func init() { - rootCmd.AddCommand(serviceAddCmd) -} diff --git a/cmd/service_destroy.go b/internal/commands/service/destroy.go similarity index 94% rename from cmd/service_destroy.go rename to internal/commands/service/destroy.go index 95f725b..faa6a6a 100644 --- a/cmd/service_destroy.go +++ b/internal/commands/service/destroy.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -var serviceDestroyCmd = &cobra.Command{ +var destroyCmd = &cobra.Command{ Use: "service:destroy ", GroupID: "service", Short: "Stop, remove container, and delete all data for a service", @@ -78,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 95% rename from cmd/service_env.go rename to internal/commands/service/env.go index f6d41df..4fd563d 100644 --- a/cmd/service_env.go +++ b/internal/commands/service/env.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -12,7 +12,7 @@ import ( "github.com/spf13/cobra" ) -var serviceEnvCmd = &cobra.Command{ +var envCmd = &cobra.Command{ Use: "service:env [service]", GroupID: "service", Short: "Print environment variables for a service", @@ -88,7 +88,3 @@ func printEnvVars(key string, envVars map[string]string) { 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 92% rename from cmd/service_list.go rename to internal/commands/service/list.go index 2bcecc1..951d71e 100644 --- a/cmd/service_list.go +++ b/internal/commands/service/list.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var serviceListCmd = &cobra.Command{ +var listCmd = &cobra.Command{ Use: "service:list", GroupID: "service", Short: "List all services", @@ -63,7 +63,3 @@ var serviceListCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceListCmd) -} diff --git a/cmd/service_logs.go b/internal/commands/service/logs.go similarity index 89% rename from cmd/service_logs.go rename to internal/commands/service/logs.go index e0fc949..41816ce 100644 --- a/cmd/service_logs.go +++ b/internal/commands/service/logs.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -8,7 +8,7 @@ import ( "github.com/spf13/cobra" ) -var serviceLogsCmd = &cobra.Command{ +var logsCmd = &cobra.Command{ Use: "service:logs ", GroupID: "service", Short: "Tail container logs for a service", @@ -37,7 +37,3 @@ var serviceLogsCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceLogsCmd) -} 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/cmd/service_remove.go b/internal/commands/service/remove.go similarity index 90% rename from cmd/service_remove.go rename to internal/commands/service/remove.go index bc5dd95..1d70cd5 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,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var serviceRemoveCmd = &cobra.Command{ +var removeCmd = &cobra.Command{ Use: "service:remove ", GroupID: "service", Short: "Stop and remove a service container (data preserved)", @@ -56,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 93% rename from cmd/service_start.go rename to internal/commands/service/start.go index 2e9d21d..9ec0292 100644 --- a/cmd/service_start.go +++ b/internal/commands/service/start.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -10,7 +10,7 @@ import ( "github.com/spf13/cobra" ) -var serviceStartCmd = &cobra.Command{ +var startCmd = &cobra.Command{ Use: "service:start [service]", GroupID: "service", Short: "Start a service or all services", @@ -64,7 +64,3 @@ var serviceStartCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceStartCmd) -} diff --git a/cmd/service_status.go b/internal/commands/service/status.go similarity index 59% rename from cmd/service_status.go rename to internal/commands/service/status.go index 4a20140..3ece0b0 100644 --- a/cmd/service_status.go +++ b/internal/commands/service/status.go @@ -1,8 +1,7 @@ -package cmd +package service import ( "fmt" - "os" "strings" "github.com/prvious/pv/internal/config" @@ -12,7 +11,7 @@ import ( "github.com/spf13/cobra" ) -var serviceStatusCmd = &cobra.Command{ +var statusCmd = &cobra.Command{ Use: "service:status ", GroupID: "service", Short: "Show detailed status for a service", @@ -51,25 +50,21 @@ var serviceStatusCmd = &cobra.Command{ 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) + rows := [][]string{ + {"Status", status}, + {"Container", svc.ContainerName(version)}, + {"Port", fmt.Sprintf(":%d", instance.Port)}, + } if instance.ConsolePort > 0 { - fmt.Fprintf(os.Stderr, " %s :%d\n", ui.Muted.Render("Console"), instance.ConsolePort) + rows = append(rows, []string{"Console", fmt.Sprintf(":%d", instance.ConsolePort)}) } - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Data"), dataDir) - + rows = append(rows, []string{"Data", dataDir}) if len(projects) > 0 { - fmt.Fprintf(os.Stderr, " %s %s\n", ui.Muted.Render("Projects"), strings.Join(projects, ", ")) + rows = append(rows, []string{"Projects", strings.Join(projects, ", ")}) } - fmt.Fprintln(os.Stderr) + + ui.Table([]string{svc.DisplayName(), version}, rows) return nil }, } - -func init() { - rootCmd.AddCommand(serviceStatusCmd) -} diff --git a/cmd/service_stop.go b/internal/commands/service/stop.go similarity index 92% rename from cmd/service_stop.go rename to internal/commands/service/stop.go index 522cc71..e219ca9 100644 --- a/cmd/service_stop.go +++ b/internal/commands/service/stop.go @@ -1,4 +1,4 @@ -package cmd +package service import ( "fmt" @@ -9,7 +9,7 @@ import ( "github.com/spf13/cobra" ) -var serviceStopCmd = &cobra.Command{ +var stopCmd = &cobra.Command{ Use: "service:stop [service]", GroupID: "service", Short: "Stop a service or all services", @@ -56,7 +56,3 @@ var serviceStopCmd = &cobra.Command{ return nil }, } - -func init() { - rootCmd.AddCommand(serviceStopCmd) -} From 4e4df41f2b9a516032a1a2ead195af064350e0cd Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 11:50:36 -0500 Subject: [PATCH 13/15] Add --force flag to uninstall to skip interactive prompts in CI huh opens /dev/tty directly, so piping stdin doesn't work in CI. Add -f/--force flag that skips the confirmation and auth backup prompts. Update e2e script to use --force instead of piping. --- cmd/uninstall.go | 27 ++++++++++++++++----------- scripts/e2e/uninstall.sh | 4 ++-- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/cmd/uninstall.go b/cmd/uninstall.go index b40012c..1887b24 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -24,6 +24,8 @@ import ( "github.com/spf13/cobra" ) +var forceUninstall bool + var uninstallCmd = &cobra.Command{ Use: "uninstall", GroupID: "core", @@ -41,21 +43,23 @@ var uninstallCmd = &cobra.Command{ ui.Subtle("") ui.Subtle("Your projects themselves will not be touched.") - 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 + 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 + } } // Auth backup offer. authPath := filepath.Join(config.ComposerDir(), "auth.json") - if hasAuthTokens(authPath) { + if !forceUninstall && hasAuthTokens(authPath) { backupAuth := true if err := huh.NewConfirm(). Title("Back up Composer auth tokens to ~/pv-auth-backup.json?"). @@ -291,5 +295,6 @@ func runSudo(script string) bool { } func init() { + uninstallCmd.Flags().BoolVarP(&forceUninstall, "force", "f", false, "Skip confirmation prompt") rootCmd.AddCommand(uninstallCmd) } 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" From 96654cd506132c7fdd4d376680efabd1dc0558fa Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 12:18:44 -0500 Subject: [PATCH 14/15] Fix PR review findings: security, nil deref, tests, and cleanups - Fix nil pointer dereference in uninstall when settings file is missing - Eliminate command injection in runSudo by using exec.Command with separate argv instead of shell interpolation via sudo sh -c - Tighten copyFile permissions from 0644 to 0600 for auth token backup - Replace raw fmt.Fprintf in doctor.go with new ui.SectionHeader helper - Deduplicate sanitizeProjectName into services.SanitizeProjectName - Add ui.Subtle message when service:env skips unknown services - Improve ErrAlreadyPrinted sentinel with descriptive error message - Update CLAUDE.md to document internal/commands// subpackages - Add registration tests for all 6 command subpackages - Add ui.Step/StepVerbose/StepProgress contract tests --- CLAUDE.md | 13 +++-- cmd/doctor.go | 7 +-- cmd/link_services.go | 6 +- cmd/uninstall.go | 23 +++++--- internal/commands/colima/register_test.go | 21 +++++++ internal/commands/composer/register_test.go | 21 +++++++ internal/commands/daemon/register_test.go | 21 +++++++ internal/commands/mago/register_test.go | 21 +++++++ internal/commands/php/register_test.go | 25 +++++++++ internal/commands/service/env.go | 12 ++-- internal/commands/service/register_test.go | 25 +++++++++ internal/services/service.go | 6 ++ internal/ui/spinner_test.go | 61 +++++++++++++++++++++ internal/ui/style.go | 7 ++- 14 files changed, 236 insertions(+), 33 deletions(-) create mode 100644 internal/commands/colima/register_test.go create mode 100644 internal/commands/composer/register_test.go create mode 100644 internal/commands/daemon/register_test.go create mode 100644 internal/commands/mago/register_test.go create mode 100644 internal/commands/php/register_test.go create mode 100644 internal/commands/service/register_test.go create mode 100644 internal/ui/spinner_test.go diff --git a/CLAUDE.md b/CLAUDE.md index bb32c33..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/`. @@ -129,4 +130,4 @@ The CLI uses a layered Charm stack: - 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/doctor.go b/cmd/doctor.go index 370b634..0ba5de0 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -58,11 +58,11 @@ var doctorCmd = &cobra.Command{ allChecks = append(allChecks, svcChecks) } - fmt.Fprintf(os.Stderr, "\n %s\n", ui.Bold.Render("pv doctor")) + ui.SectionHeader("pv doctor") passed, failed := 0, 0 for _, section := range allChecks { - fmt.Fprintf(os.Stderr, "\n %s\n", ui.Bold.Render(section.Name)) + ui.SectionHeader(section.Name) for _, c := range section.Checks { if c.Status { ui.Success(c.Name) @@ -80,7 +80,6 @@ var doctorCmd = &cobra.Command{ } } - fmt.Fprintln(os.Stderr) if failed > 0 { ui.Fail(fmt.Sprintf("%d passed, %d issues found", passed, failed)) return ui.ErrAlreadyPrinted @@ -245,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/link_services.go b/cmd/link_services.go index dd0637f..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 @@ -167,7 +167,3 @@ func containsStr(slice []string, s string) bool { } return false } - -func sanitizeProjectName(name string) string { - return strings.ReplaceAll(name, "-", "_") -} diff --git a/cmd/uninstall.go b/cmd/uninstall.go index 1887b24..c0af908 100644 --- a/cmd/uninstall.go +++ b/cmd/uninstall.go @@ -29,7 +29,7 @@ var forceUninstall bool var uninstallCmd = &cobra.Command{ Use: "uninstall", GroupID: "core", - Short: "Completely remove pv and all its data", + Short: "Completely remove pv and all its data", RunE: func(cmd *cobra.Command, args []string) error { // Confirmation prompt. ui.Subtle("This will remove:") @@ -90,7 +90,10 @@ 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 := colimacmd.RunUninstall(); err != nil { @@ -160,10 +163,11 @@ var uninstallCmd = &cobra.Command{ // 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 { // Error already displayed by ui.Step } @@ -202,7 +206,7 @@ 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) @@ -222,7 +226,7 @@ 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) @@ -283,12 +287,13 @@ 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 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/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/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/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/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/service/env.go b/internal/commands/service/env.go index 4fd563d..39b98f1 100644 --- a/internal/commands/service/env.go +++ b/internal/commands/service/env.go @@ -15,8 +15,8 @@ import ( var envCmd = &cobra.Command{ Use: "service:env [service]", GroupID: "service", - Short: "Print environment variables for a service", - Args: cobra.MaximumNArgs(1), + 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 { @@ -25,7 +25,7 @@ var envCmd = &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. @@ -45,6 +45,7 @@ var envCmd = &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) @@ -83,8 +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, "-", "_") -} 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/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/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 b7d71a8..3abab54 100644 --- a/internal/ui/style.go +++ b/internal/ui/style.go @@ -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) { @@ -48,3 +48,8 @@ func Subtle(text string) { func FailDetail(text string) { fmt.Fprintf(os.Stderr, " %s\n", Muted.Render(text)) } + +// SectionHeader prints a bold section header with surrounding spacing. +func SectionHeader(text string) { + fmt.Fprintf(os.Stderr, "\n %s\n", Bold.Render(text)) +} From 7eb6ca4ee82eec68163e65bd2f35d274603de024 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Sat, 7 Mar 2026 12:31:27 -0500 Subject: [PATCH 15/15] chore: gofmt --- AGENTS.md | 311 +----------------------- cmd/env.go | 4 +- cmd/install.go | 8 +- cmd/install_test.go | 2 +- cmd/link.go | 2 +- cmd/log.go | 2 +- cmd/restart.go | 2 +- cmd/setup.go | 4 +- cmd/start.go | 2 +- cmd/status.go | 2 +- cmd/stop.go | 2 +- cmd/unlink.go | 2 +- cmd/update.go | 8 +- docs/features/daemon.md | 44 ++-- docs/features/services.md | 14 +- install.sh | 4 +- internal/commands/composer/download.go | 2 +- internal/commands/composer/install.go | 2 +- internal/commands/composer/path.go | 2 +- internal/commands/composer/uninstall.go | 2 +- internal/commands/composer/update.go | 2 +- internal/commands/mago/download.go | 2 +- internal/commands/mago/install.go | 2 +- internal/commands/mago/path.go | 2 +- internal/commands/mago/uninstall.go | 2 +- internal/commands/mago/update.go | 2 +- internal/commands/php/download.go | 6 +- internal/commands/php/install.go | 2 +- internal/commands/php/list.go | 4 +- internal/commands/php/path.go | 2 +- internal/commands/php/remove.go | 6 +- internal/commands/php/uninstall.go | 2 +- internal/commands/php/update.go | 2 +- internal/commands/php/use.go | 4 +- internal/commands/service/add.go | 6 +- internal/commands/service/destroy.go | 4 +- internal/commands/service/list.go | 2 +- internal/commands/service/logs.go | 4 +- internal/commands/service/remove.go | 4 +- internal/commands/service/start.go | 4 +- internal/commands/service/status.go | 4 +- internal/commands/service/stop.go | 4 +- internal/container/engine.go | 16 +- internal/detection/detect_test.go | 2 +- internal/phpenv/phpenv.go | 2 +- internal/services/mail.go | 2 +- internal/services/mysql.go | 4 +- internal/services/s3.go | 2 +- internal/tools/shims.go | 3 +- internal/tools/tool.go | 4 +- internal/ui/progress.go | 7 +- internal/ui/tree.go | 4 +- 52 files changed, 114 insertions(+), 425 deletions(-) mode change 100644 => 120000 AGENTS.md 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/cmd/env.go b/cmd/env.go index 0f1db41..993dda6 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -11,8 +11,8 @@ import ( var envCmd = &cobra.Command{ Use: "env", GroupID: "core", - Short: "Print shell configuration for pv", - Long: "Print shell commands to configure PATH for pv.", + 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 { diff --git a/cmd/install.go b/cmd/install.go index 4f524b1..5f030cd 100644 --- a/cmd/install.go +++ b/cmd/install.go @@ -7,8 +7,8 @@ import ( "strings" "time" - "github.com/prvious/pv/internal/commands/mago" "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" @@ -82,7 +82,7 @@ func parseWith(raw string) (withSpec, error) { var installCmd = &cobra.Command{ Use: "install", GroupID: "core", - Short: "Non-interactive setup — installs PHP, Composer, and configures the environment", + 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 @@ -183,8 +183,8 @@ pv install --with="php:8.3,service[redis:7],service[mysql:8.0]"`, } if err := service.RunAdd(svcArgs); err != nil { if !errors.Is(err, ui.ErrAlreadyPrinted) { - ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) - } + ui.Fail(fmt.Sprintf("Service %s failed: %v", svc.name, err)) + } } } 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 f28c74e..81a5fc3 100644 --- a/cmd/link.go +++ b/cmd/link.go @@ -20,7 +20,7 @@ var linkName string var linkCmd = &cobra.Command{ Use: "link [path]", GroupID: "core", - Short: "Link a project directory", + Short: "Link a project directory", Example: `# Link the current directory pv link diff --git a/cmd/log.go b/cmd/log.go index 82a056c..be2767e 100644 --- a/cmd/log.go +++ b/cmd/log.go @@ -23,7 +23,7 @@ var ( var logCmd = &cobra.Command{ Use: "log [site]", GroupID: "core", - Short: "Tail the FrankenPHP log", + Short: "Tail the FrankenPHP log", Example: `# Tail all logs pv log diff --git a/cmd/restart.go b/cmd/restart.go index 2815784..0a1cd6e 100644 --- a/cmd/restart.go +++ b/cmd/restart.go @@ -13,7 +13,7 @@ import ( var restartCmd = &cobra.Command{ Use: "restart", GroupID: "server", - Short: "Restart or reload the pv server", + Short: "Restart or reload the pv server", RunE: func(cmd *cobra.Command, args []string) error { // Daemon mode — delegate to daemon:restart. if daemon.IsLoaded() { diff --git a/cmd/setup.go b/cmd/setup.go index 76b950e..4fa0a83 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -8,10 +8,10 @@ import ( "time" "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/binaries" "github.com/prvious/pv/internal/config" "github.com/prvious/pv/internal/phpenv" "github.com/prvious/pv/internal/services" @@ -23,7 +23,7 @@ import ( var setupCmd = &cobra.Command{ Use: "setup", GroupID: "core", - Short: "Interactive setup wizard — choose PHP versions, tools, and services", + Short: "Interactive setup wizard — choose PHP versions, tools, and services", RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() client := &http.Client{} diff --git a/cmd/start.go b/cmd/start.go index 64f05e7..f8d193b 100644 --- a/cmd/start.go +++ b/cmd/start.go @@ -19,7 +19,7 @@ var ( var startCmd = &cobra.Command{ Use: "start", GroupID: "server", - Short: "Start the pv server (DNS + FrankenPHP)", + Short: "Start the pv server (DNS + FrankenPHP)", RunE: func(cmd *cobra.Command, args []string) error { if startBackground { return startDaemon() diff --git a/cmd/status.go b/cmd/status.go index 0457604..c476aac 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -17,7 +17,7 @@ import ( var statusCmd = &cobra.Command{ Use: "status", GroupID: "core", - Short: "Show pv server status", + 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 81dcb56..09797fb 100644 --- a/cmd/stop.go +++ b/cmd/stop.go @@ -15,7 +15,7 @@ import ( var stopCmd = &cobra.Command{ Use: "stop", GroupID: "server", - Short: "Stop the pv server", + Short: "Stop the pv server", RunE: func(cmd *cobra.Command, args []string) error { // Check daemon mode first. if daemon.IsLoaded() { diff --git a/cmd/unlink.go b/cmd/unlink.go index 40a293f..1befd3b 100644 --- a/cmd/unlink.go +++ b/cmd/unlink.go @@ -16,7 +16,7 @@ import ( var unlinkCmd = &cobra.Command{ Use: "unlink [name]", GroupID: "core", - Short: "Unlink a project", + Short: "Unlink a project", Example: `# Unlink by name pv unlink myapp diff --git a/cmd/update.go b/cmd/update.go index 60ccebf..04bdfed 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -9,25 +9,25 @@ import ( "syscall" "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/colima" "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", GroupID: "core", - Short: "Update pv and all managed tools to their latest versions", + Short: "Update pv and all managed tools to their latest versions", RunE: func(cmd *cobra.Command, args []string) error { start := time.Now() 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/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/internal/commands/composer/download.go b/internal/commands/composer/download.go index bccb5bb..2b72675 100644 --- a/internal/commands/composer/download.go +++ b/internal/commands/composer/download.go @@ -13,7 +13,7 @@ import ( var downloadCmd = &cobra.Command{ Use: "composer:download", GroupID: "composer", - Short: "Download Composer to internal storage", + Short: "Download Composer to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/internal/commands/composer/install.go b/internal/commands/composer/install.go index bf9c3b9..28baa02 100644 --- a/internal/commands/composer/install.go +++ b/internal/commands/composer/install.go @@ -10,7 +10,7 @@ import ( var installCmd = &cobra.Command{ Use: "composer:install", GroupID: "composer", - Short: "Install or update Composer", + Short: "Install or update Composer", RunE: func(cmd *cobra.Command, args []string) error { // Download. if err := downloadCmd.RunE(downloadCmd, nil); err != nil { diff --git a/internal/commands/composer/path.go b/internal/commands/composer/path.go index 953fa04..2995aaa 100644 --- a/internal/commands/composer/path.go +++ b/internal/commands/composer/path.go @@ -13,7 +13,7 @@ var pathRemove bool var pathCmd = &cobra.Command{ Use: "composer:path", GroupID: "composer", - Short: "Expose or remove Composer from PATH", + Short: "Expose or remove Composer from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("composer") diff --git a/internal/commands/composer/uninstall.go b/internal/commands/composer/uninstall.go index dd089d2..3f3306e 100644 --- a/internal/commands/composer/uninstall.go +++ b/internal/commands/composer/uninstall.go @@ -13,7 +13,7 @@ import ( var uninstallCmd = &cobra.Command{ Use: "composer:uninstall", GroupID: "composer", - Short: "Remove Composer PHAR, PATH entry, and global packages", + 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 { diff --git a/internal/commands/composer/update.go b/internal/commands/composer/update.go index cda0a5a..ee1c40d 100644 --- a/internal/commands/composer/update.go +++ b/internal/commands/composer/update.go @@ -10,7 +10,7 @@ import ( var updateCmd = &cobra.Command{ Use: "composer:update", GroupID: "composer", - Short: "Update Composer to the latest version", + 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 := downloadCmd.RunE(downloadCmd, nil); err != nil { diff --git a/internal/commands/mago/download.go b/internal/commands/mago/download.go index 2ee873c..f7f3680 100644 --- a/internal/commands/mago/download.go +++ b/internal/commands/mago/download.go @@ -13,7 +13,7 @@ import ( var downloadCmd = &cobra.Command{ Use: "mago:download", GroupID: "mago", - Short: "Download Mago to internal storage", + Short: "Download Mago to internal storage", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/internal/commands/mago/install.go b/internal/commands/mago/install.go index e43e5e6..1e12557 100644 --- a/internal/commands/mago/install.go +++ b/internal/commands/mago/install.go @@ -10,7 +10,7 @@ import ( var installCmd = &cobra.Command{ Use: "mago:install", GroupID: "mago", - Short: "Install or update Mago", + Short: "Install or update Mago", RunE: func(cmd *cobra.Command, args []string) error { // Download. if err := downloadCmd.RunE(downloadCmd, nil); err != nil { diff --git a/internal/commands/mago/path.go b/internal/commands/mago/path.go index 92ce342..0e11161 100644 --- a/internal/commands/mago/path.go +++ b/internal/commands/mago/path.go @@ -13,7 +13,7 @@ var pathRemove bool var pathCmd = &cobra.Command{ Use: "mago:path", GroupID: "mago", - Short: "Expose or remove Mago from PATH", + Short: "Expose or remove Mago from PATH", RunE: func(cmd *cobra.Command, args []string) error { t := tools.MustGet("mago") diff --git a/internal/commands/mago/uninstall.go b/internal/commands/mago/uninstall.go index 7a559aa..43818c1 100644 --- a/internal/commands/mago/uninstall.go +++ b/internal/commands/mago/uninstall.go @@ -13,7 +13,7 @@ import ( var uninstallCmd = &cobra.Command{ Use: "mago:uninstall", GroupID: "mago", - Short: "Remove Mago binary and PATH entry", + 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 { diff --git a/internal/commands/mago/update.go b/internal/commands/mago/update.go index 196ac40..540d348 100644 --- a/internal/commands/mago/update.go +++ b/internal/commands/mago/update.go @@ -13,7 +13,7 @@ import ( var updateCmd = &cobra.Command{ Use: "mago:update", GroupID: "mago", - Short: "Update Mago to the latest version", + Short: "Update Mago to the latest version", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/internal/commands/php/download.go b/internal/commands/php/download.go index 4109ac0..f1ec792 100644 --- a/internal/commands/php/download.go +++ b/internal/commands/php/download.go @@ -10,10 +10,10 @@ import ( ) var downloadCmd = &cobra.Command{ - Use: "php:download ", + Use: "php:download [version]", GroupID: "php", - Short: "Download PHP + FrankenPHP to internal storage", - Args: cobra.ExactArgs(1), + 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) { diff --git a/internal/commands/php/install.go b/internal/commands/php/install.go index beeb4ca..41d0144 100644 --- a/internal/commands/php/install.go +++ b/internal/commands/php/install.go @@ -16,7 +16,7 @@ var validPHPVersion = regexp.MustCompile(`^\d+\.\d+$`) 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.", + 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 diff --git a/internal/commands/php/list.go b/internal/commands/php/list.go index afb6ecf..0811c8c 100644 --- a/internal/commands/php/list.go +++ b/internal/commands/php/list.go @@ -14,7 +14,7 @@ import ( var listCmd = &cobra.Command{ Use: "php:list", GroupID: "php", - Short: "List installed PHP versions", + Short: "List installed PHP versions", RunE: func(cmd *cobra.Command, args []string) error { versions, err := phpenv.InstalledVersions() if err != nil { @@ -22,7 +22,7 @@ var listCmd = &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 } diff --git a/internal/commands/php/path.go b/internal/commands/php/path.go index ba13efc..37a4319 100644 --- a/internal/commands/php/path.go +++ b/internal/commands/php/path.go @@ -13,7 +13,7 @@ var pathRemove bool var pathCmd = &cobra.Command{ Use: "php:path", GroupID: "php", - Short: "Expose or remove PHP and FrankenPHP from PATH", + Short: "Expose or remove PHP and FrankenPHP from PATH", RunE: func(cmd *cobra.Command, args []string) error { php := tools.MustGet("php") fp := tools.MustGet("frankenphp") diff --git a/internal/commands/php/remove.go b/internal/commands/php/remove.go index 2b96949..f6dac05 100644 --- a/internal/commands/php/remove.go +++ b/internal/commands/php/remove.go @@ -11,10 +11,10 @@ import ( ) var removeCmd = &cobra.Command{ - Use: "php:remove ", + Use: "php:remove [version]", GroupID: "php", - Short: "Remove an installed PHP version", - Args: cobra.ExactArgs(1), + 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) { diff --git a/internal/commands/php/uninstall.go b/internal/commands/php/uninstall.go index ca2b611..388f623 100644 --- a/internal/commands/php/uninstall.go +++ b/internal/commands/php/uninstall.go @@ -13,7 +13,7 @@ import ( var uninstallCmd = &cobra.Command{ Use: "php:uninstall", GroupID: "php", - Short: "Remove all PHP versions and PATH entries", + 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"} { diff --git a/internal/commands/php/update.go b/internal/commands/php/update.go index 7b1636e..bf3032e 100644 --- a/internal/commands/php/update.go +++ b/internal/commands/php/update.go @@ -13,7 +13,7 @@ import ( var updateCmd = &cobra.Command{ Use: "php:update", GroupID: "php", - Short: "Re-download all installed PHP versions with the latest builds", + Short: "Re-download all installed PHP versions with the latest builds", RunE: func(cmd *cobra.Command, args []string) error { client := &http.Client{} diff --git a/internal/commands/php/use.go b/internal/commands/php/use.go index 462c5cc..e0a963f 100644 --- a/internal/commands/php/use.go +++ b/internal/commands/php/use.go @@ -12,9 +12,9 @@ import ( ) var useCmd = &cobra.Command{ - Use: "php:use ", + Use: "php:use [version]", GroupID: "php", - Short: "Switch the global PHP version (e.g., pv php:use 8.4)", + 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), diff --git a/internal/commands/service/add.go b/internal/commands/service/add.go index 74f8e7b..b14b147 100644 --- a/internal/commands/service/add.go +++ b/internal/commands/service/add.go @@ -5,9 +5,9 @@ import ( "fmt" "os" - colimacmds "github.com/prvious/pv/internal/commands/colima" "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" @@ -19,8 +19,8 @@ import ( 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.", + 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 diff --git a/internal/commands/service/destroy.go b/internal/commands/service/destroy.go index faa6a6a..47c536e 100644 --- a/internal/commands/service/destroy.go +++ b/internal/commands/service/destroy.go @@ -15,8 +15,8 @@ import ( var destroyCmd = &cobra.Command{ Use: "service:destroy ", GroupID: "service", - Short: "Stop, remove container, and delete all data for a service", - Args: cobra.ExactArgs(1), + 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] diff --git a/internal/commands/service/list.go b/internal/commands/service/list.go index 951d71e..cead375 100644 --- a/internal/commands/service/list.go +++ b/internal/commands/service/list.go @@ -13,7 +13,7 @@ import ( var listCmd = &cobra.Command{ Use: "service:list", GroupID: "service", - Short: "List all services", + Short: "List all services", RunE: func(cmd *cobra.Command, args []string) error { reg, err := registry.Load() if err != nil { diff --git a/internal/commands/service/logs.go b/internal/commands/service/logs.go index 41816ce..b4be9e3 100644 --- a/internal/commands/service/logs.go +++ b/internal/commands/service/logs.go @@ -11,8 +11,8 @@ import ( var logsCmd = &cobra.Command{ Use: "service:logs ", GroupID: "service", - Short: "Tail container logs for a service", - Args: cobra.ExactArgs(1), + Short: "Tail container logs for a service", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] diff --git a/internal/commands/service/remove.go b/internal/commands/service/remove.go index 1d70cd5..c8e27b9 100644 --- a/internal/commands/service/remove.go +++ b/internal/commands/service/remove.go @@ -14,8 +14,8 @@ import ( var removeCmd = &cobra.Command{ Use: "service:remove ", GroupID: "service", - Short: "Stop and remove a service container (data preserved)", - Args: cobra.ExactArgs(1), + Short: "Stop and remove a service container (data preserved)", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] diff --git a/internal/commands/service/start.go b/internal/commands/service/start.go index 9ec0292..f4db6e8 100644 --- a/internal/commands/service/start.go +++ b/internal/commands/service/start.go @@ -13,8 +13,8 @@ import ( var startCmd = &cobra.Command{ Use: "service:start [service]", GroupID: "service", - Short: "Start a service or all services", - Args: cobra.MaximumNArgs(1), + 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 { diff --git a/internal/commands/service/status.go b/internal/commands/service/status.go index 3ece0b0..8235eab 100644 --- a/internal/commands/service/status.go +++ b/internal/commands/service/status.go @@ -14,8 +14,8 @@ import ( var statusCmd = &cobra.Command{ Use: "service:status ", GroupID: "service", - Short: "Show detailed status for a service", - Args: cobra.ExactArgs(1), + Short: "Show detailed status for a service", + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { key := args[0] diff --git a/internal/commands/service/stop.go b/internal/commands/service/stop.go index e219ca9..9431661 100644 --- a/internal/commands/service/stop.go +++ b/internal/commands/service/stop.go @@ -12,8 +12,8 @@ import ( var stopCmd = &cobra.Command{ Use: "service:stop [service]", GroupID: "service", - Short: "Stop a service or all services", - Args: cobra.MaximumNArgs(1), + 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 { 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/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/tree.go b/internal/ui/tree.go index a49673e..f20c2a6 100644 --- a/internal/ui/tree.go +++ b/internal/ui/tree.go @@ -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)