From cd0834082eaf250f0f49c44e45ea5de372c39b17 Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 23 Apr 2026 00:19:12 +0800 Subject: [PATCH 1/2] refactor(cli): make openboot install the canonical entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root command no longer doubles as install — bare `openboot` now shows help instead of running the install wizard. All install flags move to `openboot install`, which is now the single, explicit entry point. - Remove RunE and all install flags from root command - Add missing --post-install flag to installCmd (was root-only before) - Update install.sh to exec `openboot install` instead of bare `openboot` (snapshot mode still execs `openboot snapshot` via passthrough) - Simplify PersistentPreRunE: map lookup → single cmd.Name() == "install" check Breaking: `openboot -p developer` style shortcuts no longer work; use `openboot install -p developer` instead. --- internal/cli/install.go | 27 ++++++++++++++---------- internal/cli/root.go | 46 ++++++++--------------------------------- scripts/install.sh | 6 +++++- 3 files changed, 30 insertions(+), 49 deletions(-) diff --git a/internal/cli/install.go b/internal/cli/install.go index 3e527c1..58e62fd 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -27,28 +27,32 @@ var installCmd = &cobra.Command{ Short: "Set up your Mac dev environment", Long: `Install and configure your Mac development environment. -Source resolution (position argument, in order): +Source resolution (positional argument, in order): 1. ./path, /path, or *.json → local file 2. user/slug → openboot.dev config 3. preset name → built-in preset (minimal, developer, full) 4. other word → treated as an openboot.dev alias -With no arguments, resumes from your saved sync source (or interactive if none). +With no arguments, resumes from your saved sync source (or runs the interactive +wizard if you have never synced before). -Explicit flags (--from, --user, -p) override the positional argument.`, - Example: ` # Resume last sync (or interactive if never synced) +Explicit flags (--from, --user, -p) take precedence over the positional argument.`, + Example: ` # Interactive setup (or resume last sync) openboot install - # Install from a cloud config - openboot install alice/dev-setup + # Quick setup with a built-in preset + openboot install -p developer - # Install from a local file - openboot install ./backup.json + # Install from your cloud config + openboot install -u githubusername - # Install a built-in preset - openboot install -p developer + # Install from a specific cloud config + openboot install alice/dev-setup + + # Install from a local file or snapshot + openboot install --from ./backup.json - # Preview without installing + # Preview changes without installing openboot install --dry-run`, Args: cobra.MaximumNArgs(1), SilenceUsage: true, @@ -68,6 +72,7 @@ func init() { installCmd.Flags().StringVar(&installCfg.Shell, "shell", "", "shell setup: install, skip") installCmd.Flags().StringVar(&installCfg.Macos, "macos", "", "macOS preferences: configure, skip") installCmd.Flags().StringVar(&installCfg.Dotfiles, "dotfiles", "", "dotfiles: clone, link, skip") + installCmd.Flags().StringVar(&installCfg.PostInstall, "post-install", "", "post-install script: skip") installCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew before installing") installCmd.Flags().BoolVar(&installCfg.AllowPostInstall, "allow-post-install", false, "allow post-install scripts in silent mode") diff --git a/internal/cli/root.go b/internal/cli/root.go index 564123c..b2a4084 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -18,25 +18,26 @@ var version = "dev" var rootCmd = &cobra.Command{ Use: "openboot", - Short: "Set up your Mac dev environment in one command", - Long: `OpenBoot - Mac development environment setup tool + Short: "Set up your Mac dev environment", + Long: `OpenBoot — Mac development environment setup tool Automates installation of Homebrew packages, CLI tools, GUI apps, npm packages, shell configuration, and macOS preferences.`, - Example: ` # Interactive setup with package selection - openboot + Example: ` # Interactive setup + openboot install # Quick setup with a preset - openboot -p developer + openboot install -p developer # Install from your cloud config - openboot -u githubusername + openboot install -u githubusername # Install from a local config or snapshot file - openboot --from config.json + openboot install --from config.json # Capture your current environment openboot snapshot --json > my-setup.json`, + SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Install always-on file logging; --verbose controls stderr level. // Failure here is never fatal — Init falls back to stderr internally. @@ -52,46 +53,17 @@ shell configuration, and macOS preferences.`, // Only the install flow needs the package catalog and auto-update. // All other commands (snapshot, login, logout, etc.) run without // network overhead. - installCmds := map[string]bool{ - "openboot": true, // root command delegates to install - "install": true, - } - if installCmds[cmd.Name()] { + if cmd.Name() == "install" { updater.AutoUpgrade(version) config.RefreshPackagesFromRemote() } return nil }, - RunE: func(cmd *cobra.Command, args []string) error { - // `openboot` with no subcommand is equivalent to `openboot install`. - // Root flags bind directly to installCfg, so no bridging is needed. - return runInstallCmd(cmd, args) - }, } func init() { rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enable debug logging to stderr") - rootCmd.Flags().SortFlags = false - - // Root is an alias for `openboot install`, so its flags bind directly to - // installCfg — the same struct used by the install subcommand. This ensures - // `openboot -p developer` and `openboot install -p developer` are identical - // code paths with no config divergence. - rootCmd.Flags().StringVarP(&installCfg.Preset, "preset", "p", "", "use a preset: minimal, developer, full") - rootCmd.Flags().StringVarP(&installCfg.User, "user", "u", "", "install from openboot.dev/username config") - rootCmd.Flags().String("from", "", "install from a local config or snapshot JSON file") - rootCmd.Flags().BoolVarP(&installCfg.Silent, "silent", "s", false, "non-interactive mode (for CI/CD)") - rootCmd.Flags().BoolVar(&installCfg.DryRun, "dry-run", false, "preview changes without installing") - rootCmd.Flags().BoolVar(&installCfg.PackagesOnly, "packages-only", false, "install packages only, skip system config") - - rootCmd.Flags().StringVar(&installCfg.Shell, "shell", "", "shell setup: install, skip") - rootCmd.Flags().StringVar(&installCfg.Macos, "macos", "", "macOS preferences: configure, skip") - rootCmd.Flags().StringVar(&installCfg.Dotfiles, "dotfiles", "", "dotfiles: clone, link, skip") - rootCmd.Flags().StringVar(&installCfg.PostInstall, "post-install", "", "post-install script: skip") - rootCmd.Flags().BoolVar(&installCfg.AllowPostInstall, "allow-post-install", false, "allow post-install scripts in silent mode") - - rootCmd.Flags().BoolVar(&installCfg.Update, "update", false, "update Homebrew before installing") rootCmd.AddCommand(installCmd) rootCmd.AddCommand(versionCmd) diff --git a/scripts/install.sh b/scripts/install.sh index bb00ad8..3ae119a 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -188,7 +188,11 @@ main() { echo "Starting OpenBoot setup..." echo "" - exec openboot "$@" + if [[ "$snapshot_mode" == true ]]; then + exec openboot "$@" + else + exec openboot install "$@" + fi } main "$@" From e7103c478b2ee192de958a3df9694ba33a36643c Mon Sep 17 00:00:00 2001 From: fullstackjam Date: Thu, 23 Apr 2026 00:25:25 +0800 Subject: [PATCH 2/2] fix: update all callers to use openboot install subcommand Fixes CI failures from the root-command refactor. The mock server's inline install script and all e2e test exec.Command calls were still using the old root-level flag syntax. - scripts/mock-server.py: generated install script now calls `openboot install -s -u` instead of bare `openboot -s -u` - test/e2e/smoke_test.go: dry-run exec call updated - test/e2e/real_install_test.go: all exec calls updated (L6 tier) - test/e2e/macos_defaults_e2e_test.go: doc comment updated - test/e2e/dotfiles_e2e_test.go: doc comments updated - README.md, .github/pull_request_template.md: examples updated - internal/cli/install.go: remove stale comment about root alias --- .github/pull_request_template.md | 2 +- README.md | 2 +- internal/cli/install.go | 4 +--- scripts/mock-server.py | 2 +- test/e2e/dotfiles_e2e_test.go | 4 ++-- test/e2e/macos_defaults_e2e_test.go | 2 +- test/e2e/real_install_test.go | 10 +++++----- test/e2e/smoke_test.go | 2 +- 8 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 60629e5..63a3f3b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -12,7 +12,7 @@ - [ ] `go vet ./...` passes - [ ] Relevant tests added or updated -- [ ] Tested locally (`./openboot --dry-run` or similar) +- [ ] Tested locally (`./openboot install --dry-run` or similar) ## Notes for reviewer diff --git a/README.md b/README.md index b3309be..97d6e35 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ Bug reports and feature requests: [open an issue](https://github.com/openbootdot git clone https://github.com/openbootdotdev/openboot.git cd openboot go build -o openboot ./cmd/openboot -./openboot --dry-run +./openboot install --dry-run ``` diff --git a/internal/cli/install.go b/internal/cli/install.go index 58e62fd..9536c6a 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -17,9 +17,7 @@ import ( "github.com/openbootdotdev/openboot/internal/ui" ) -// installCfg is the single config instance shared by the root command (openboot) -// and the install subcommand (openboot install). Both bind their flags here so -// that `openboot -p developer` and `openboot install -p developer` are identical. +// installCfg is the config instance used by the install subcommand. var installCfg = &config.Config{} var installCmd = &cobra.Command{ diff --git a/scripts/mock-server.py b/scripts/mock-server.py index 66b8b93..de0ca6c 100644 --- a/scripts/mock-server.py +++ b/scripts/mock-server.py @@ -53,7 +53,7 @@ fi export OPENBOOT_DRY_RUN=true export OPENBOOT_API_URL=http://localhost:{port} -{binary} -s -u testuser/test-config +{binary} install -s -u testuser/test-config exit 0 }} main diff --git a/test/e2e/dotfiles_e2e_test.go b/test/e2e/dotfiles_e2e_test.go index 26609b4..180199a 100644 --- a/test/e2e/dotfiles_e2e_test.go +++ b/test/e2e/dotfiles_e2e_test.go @@ -22,7 +22,7 @@ import ( // TestVM_Journey_DotfilesClonedAndLinked runs // -// openboot --preset minimal --silent --dotfiles clone --shell skip --macos skip +// openboot install --preset minimal --silent --dotfiles clone --shell skip --macos skip // // and verifies that: // 1. ~/.dotfiles is a valid git repository (clone succeeded). @@ -112,7 +112,7 @@ func TestVM_Journey_DotfilesClonedAndLinked(t *testing.T) { // TestVM_Journey_DotfilesLink_OnlyLinks runs // -// openboot --preset minimal --silent --dotfiles link --shell skip --macos skip +// openboot install --preset minimal --silent --dotfiles link --shell skip --macos skip // // when ~/.dotfiles already exists (from a previous clone), verifying that the // link-only mode does not re-clone but still creates symlinks. diff --git a/test/e2e/macos_defaults_e2e_test.go b/test/e2e/macos_defaults_e2e_test.go index 4de0f94..d43d345 100644 --- a/test/e2e/macos_defaults_e2e_test.go +++ b/test/e2e/macos_defaults_e2e_test.go @@ -31,7 +31,7 @@ type macOSPrefCheck struct { // TestVM_Journey_MacOSDefaults_AllCategoriesWritten runs // -// openboot --preset minimal --silent --shell skip --dotfiles skip --macos configure +// openboot install --preset minimal --silent --shell skip --dotfiles skip --macos configure // // and verifies that representative preferences from each of the eight // categories in internal/macos/categories.go are actually written to the diff --git a/test/e2e/real_install_test.go b/test/e2e/real_install_test.go index 999b666..3444607 100644 --- a/test/e2e/real_install_test.go +++ b/test/e2e/real_install_test.go @@ -25,7 +25,7 @@ func TestE2E_InstallSinglePackage_JQ(t *testing.T) { binary := testutil.BuildTestBinary(t) // When: we install jq via openboot (minimal preset includes jq) - cmd := exec.Command(binary, "--packages-only", "--silent", "--preset", "minimal") + cmd := exec.Command(binary, "install", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME=Test User", "OPENBOOT_GIT_EMAIL=test@example.com", @@ -67,7 +67,7 @@ func TestE2E_InstallMultiplePackages(t *testing.T) { }`) defer os.Remove(tmpConfig) - cmd := exec.Command(binary, "--packages-only", "--silent", "--preset", "minimal") + cmd := exec.Command(binary, "install", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME=Test User", "OPENBOOT_GIT_EMAIL=test@example.com", @@ -183,7 +183,7 @@ func TestE2E_DryRunDoesNotInstall(t *testing.T) { }`) defer os.Remove(tmpConfig) - cmd := exec.Command(binary, "--dry-run", "--packages-only", "--silent", "--preset", "minimal") + cmd := exec.Command(binary, "install", "--dry-run", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME=Test User", "OPENBOOT_GIT_EMAIL=test@example.com", @@ -201,7 +201,7 @@ func TestE2E_BrewUpdateBeforeInstall(t *testing.T) { binary := testutil.BuildTestBinary(t) // Given: we request brew update - cmd := exec.Command(binary, "--update", "--dry-run", "--packages-only", "--silent", "--preset", "minimal") + cmd := exec.Command(binary, "install", "--update", "--dry-run", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME=Test User", "OPENBOOT_GIT_EMAIL=test@example.com", @@ -224,7 +224,7 @@ func TestE2E_GitConfigSetup(t *testing.T) { testEmail := "e2e-test@example.com" // Given: we have test git credentials - cmd := exec.Command(binary, "--packages-only", "--silent", "--preset", "minimal") + cmd := exec.Command(binary, "install", "--packages-only", "--silent", "--preset", "minimal") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME="+testName, "OPENBOOT_GIT_EMAIL="+testEmail, diff --git a/test/e2e/smoke_test.go b/test/e2e/smoke_test.go index df386c6..09390c0 100644 --- a/test/e2e/smoke_test.go +++ b/test/e2e/smoke_test.go @@ -80,7 +80,7 @@ func TestSmoke_DryRunNoSideEffects(t *testing.T) { before := captureSnapshot(t, binary) // When: run with --dry-run --preset full - cmd := exec.Command(binary, "--preset", "full", "--dry-run", "--silent") + cmd := exec.Command(binary, "install", "--preset", "full", "--dry-run", "--silent") cmd.Env = append(os.Environ(), "OPENBOOT_GIT_NAME=Smoke Test", "OPENBOOT_GIT_EMAIL=smoke@test.local",