diff --git a/cmd/root.go b/cmd/root.go index 6116d54..d397e88 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -90,7 +90,7 @@ func isAuthExempt(cmd *cobra.Command) bool { // Check if the top-level command is in the exempt list switch topLevel.Name() { - case "login", "logout", "auth", "help", "completion", "create", "mcp": + case "login", "logout", "auth", "help", "completion", "create", "mcp", "upgrade": return true } @@ -141,6 +141,7 @@ func init() { rootCmd.AddCommand(extensionsCmd) rootCmd.AddCommand(createCmd) rootCmd.AddCommand(mcp.MCPCmd) + rootCmd.AddCommand(upgradeCmd) rootCmd.PersistentPostRunE = func(cmd *cobra.Command, args []string) error { // running synchronously so we never slow the command diff --git a/cmd/upgrade.go b/cmd/upgrade.go new file mode 100644 index 0000000..740ed40 --- /dev/null +++ b/cmd/upgrade.go @@ -0,0 +1,202 @@ +package cmd + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "os/exec" + "runtime" + "strings" + "time" + + "github.com/kernel/cli/pkg/update" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// UpgradeInput holds the input parameters for the upgrade command. +type UpgradeInput struct { + DryRun bool +} + +// UpgradeCmd handles the upgrade command logic, separated from cobra. +type UpgradeCmd struct { + currentVersion string +} + +// Run executes the upgrade command logic. +func (u UpgradeCmd) Run(ctx context.Context, in UpgradeInput) error { + // Fetch latest version from GitHub + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + pterm.Info.Println("Checking for updates...") + + latestTag, releaseURL, err := update.FetchLatest(ctx) + if err != nil { + return fmt.Errorf("failed to check for updates: %w", err) + } + + // Compare versions + isNewer, err := update.IsNewerVersion(u.currentVersion, latestTag) + if err != nil { + // If version comparison fails (e.g., dev version), still allow upgrade + pterm.Warning.Printf("Could not compare versions (%s vs %s): %v\n", u.currentVersion, latestTag, err) + pterm.Info.Println("Proceeding with upgrade...") + } else if !isNewer { + pterm.Success.Printf("You are already on the latest version (%s)\n", strings.TrimPrefix(u.currentVersion, "v")) + return nil + } else { + pterm.Info.Printf("New version available: %s → %s\n", strings.TrimPrefix(u.currentVersion, "v"), strings.TrimPrefix(latestTag, "v")) + if releaseURL != "" { + pterm.Info.Printf("Release notes: %s\n", releaseURL) + } + } + + // Detect installation method + method, binaryPath := update.DetectInstallMethod() + + if method == update.InstallMethodUnknown { + printManualUpgradeInstructions(latestTag, binaryPath) + // Return nil since we've provided manual instructions - don't fail scripts + return nil + } + + if in.DryRun { + pterm.Info.Printf("Detected installation method: %s\n", method) + pterm.Info.Printf("Binary path: %s\n", binaryPath) + pterm.Info.Printf("Would run: %s\n", getUpgradeCommand(method)) + return nil + } + + pterm.Info.Printf("Upgrading via %s...\n", method) + stderr, err := executeUpgrade(method) + if err != nil { + // If Homebrew upgrade fails, check if it's due to old tap installation + if method == update.InstallMethodBrew && isOldTapError(stderr) { + pterm.Println() + pterm.Error.Println("Homebrew upgrade failed due to old tap installation.") + pterm.Info.Println("Run these commands to fix:") + pterm.Println() + fmt.Println(" brew uninstall kernel") + fmt.Println(" brew untap onkernel/tap") + fmt.Println(" brew install kernel/tap/kernel") + pterm.Println() + } + return err + } + return nil +} + +// isOldTapError checks if the brew error output indicates the user has the old onkernel/tap +// installed and needs to migrate to kernel/tap. +func isOldTapError(stderr string) bool { + // When a user has onkernel/tap/kernel installed and runs `brew upgrade kernel/tap/kernel`, + // Homebrew will suggest: "Please tap it and then try again: brew tap kernel/tap" + return strings.Contains(stderr, "brew tap kernel/tap") +} + +// upgradeCommandArgs returns the command and arguments for a given installation method. +// Returns nil if the method is unknown. +func upgradeCommandArgs(method update.InstallMethod) []string { + switch method { + case update.InstallMethodBrew: + return []string{"brew", "upgrade", "kernel/tap/kernel"} + case update.InstallMethodPNPM: + return []string{"pnpm", "add", "-g", "@onkernel/cli@latest"} + case update.InstallMethodNPM: + return []string{"npm", "i", "-g", "@onkernel/cli@latest"} + case update.InstallMethodBun: + return []string{"bun", "add", "-g", "@onkernel/cli@latest"} + default: + return nil + } +} + +// getUpgradeCommand returns the command string for display (e.g., dry-run output). +func getUpgradeCommand(method update.InstallMethod) string { + args := upgradeCommandArgs(method) + if args == nil { + return "" + } + return strings.Join(args, " ") +} + +// executeUpgrade runs the appropriate upgrade command based on the installation method. +// Returns the captured stderr (for error diagnosis) and any error. +func executeUpgrade(method update.InstallMethod) (stderr string, err error) { + args := upgradeCommandArgs(method) + if args == nil { + return "", fmt.Errorf("unknown installation method") + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + + // Capture stderr while also displaying it to the user + var stderrBuf bytes.Buffer + cmd.Stderr = io.MultiWriter(os.Stderr, &stderrBuf) + + err = cmd.Run() + return stderrBuf.String(), err +} + +// printManualUpgradeInstructions prints instructions for manually upgrading kernel +func printManualUpgradeInstructions(version, binaryPath string) { + // Normalize version (remove 'v' prefix if present) + version = strings.TrimPrefix(version, "v") + + goos := runtime.GOOS + goarch := runtime.GOARCH + + downloadURL := fmt.Sprintf( + "https://github.com/kernel/cli/releases/download/v%s/kernel_%s_%s_%s.tar.gz", + version, version, goos, goarch, + ) + + if binaryPath == "" { + binaryPath = "/usr/local/bin/kernel" + } + + pterm.Warning.Println("Could not detect installation method.") + pterm.Info.Println("To upgrade manually, run:") + pterm.Println() + fmt.Printf(" wget %s -O /tmp/kernel.tar.gz\n", downloadURL) + fmt.Printf(" tar -xzf /tmp/kernel.tar.gz -C /tmp\n") + fmt.Printf(" sudo cp /tmp/kernel %q\n", binaryPath) + pterm.Println() +} + +var upgradeCmd = &cobra.Command{ + Use: "upgrade", + Aliases: []string{"update"}, + Short: "Upgrade the Kernel CLI to the latest version", + Long: `Upgrade the Kernel CLI to the latest version. + +Supported installation methods: + - Homebrew (brew) + - pnpm + - npm + - bun + +If your installation method cannot be detected, manual upgrade instructions will be provided.`, + RunE: runUpgrade, +} + +func init() { + upgradeCmd.Flags().Bool("dry-run", false, "Show what would be executed without running") +} + +func runUpgrade(cmd *cobra.Command, args []string) error { + dryRun, _ := cmd.Flags().GetBool("dry-run") + + u := UpgradeCmd{ + currentVersion: metadata.Version, + } + return u.Run(cmd.Context(), UpgradeInput{ + DryRun: dryRun, + }) +} diff --git a/pkg/update/check.go b/pkg/update/check.go index e74b941..f2cea5d 100644 --- a/pkg/update/check.go +++ b/pkg/update/check.go @@ -59,8 +59,8 @@ func isSemverLike(v string) bool { return err == nil } -// isNewerVersion reports whether latest > current using semver rules. -func isNewerVersion(current, latest string) (bool, error) { +// IsNewerVersion reports whether latest > current using semver rules. +func IsNewerVersion(current, latest string) (bool, error) { c := normalizeSemver(current) l := normalizeSemver(latest) if c == "" || l == "" { @@ -77,10 +77,10 @@ func isNewerVersion(current, latest string) (bool, error) { return lv.GreaterThan(cv), nil } -// fetchLatest queries GitHub Releases and returns the latest stable tag and URL. +// FetchLatest queries GitHub Releases and returns the latest stable tag and URL. // It expects that the GitHub API returns releases in descending chronological order // (newest first), which is standard behavior. -func fetchLatest(ctx context.Context) (tag string, url string, err error) { +func FetchLatest(ctx context.Context) (tag string, url string, err error) { apiURL := os.Getenv("KERNEL_RELEASES_URL") if apiURL == "" { apiURL = defaultReleasesAPI @@ -172,13 +172,13 @@ func MaybeShowMessage(ctx context.Context, currentVersion string, frequency time ctx, cancel := context.WithTimeout(ctx, requestTimeout) defer cancel() - latestTag, releaseURL, err := fetchLatest(ctx) + latestTag, releaseURL, err := FetchLatest(ctx) if err != nil { cache.LastChecked = time.Now().UTC() _ = saveCache(cachePath, cache) return } - isNewer, err := isNewerVersion(currentVersion, latestTag) + isNewer, err := IsNewerVersion(currentVersion, latestTag) if err != nil || !isNewer { cache.LastChecked = time.Now().UTC() _ = saveCache(cachePath, cache) @@ -234,19 +234,36 @@ func saveCache(path string, c Cache) error { return os.WriteFile(path, b, 0o600) } -// SuggestUpgradeCommand attempts to infer how the user installed kernel and -// returns a tailored upgrade command. Falls back to empty string on unknown. -func SuggestUpgradeCommand() string { +// InstallMethod represents how kernel was installed +type InstallMethod string + +const ( + InstallMethodBrew InstallMethod = "brew" + InstallMethodPNPM InstallMethod = "pnpm" + InstallMethodNPM InstallMethod = "npm" + InstallMethodBun InstallMethod = "bun" + InstallMethodUnknown InstallMethod = "unknown" +) + +// DetectInstallMethod detects how kernel was installed and returns the method +// along with the path to the kernel binary. +func DetectInstallMethod() (InstallMethod, string) { // Collect candidate paths: current executable and shell-resolved binary candidates := []string{} + binaryPath := "" + if exe, err := os.Executable(); err == nil && exe != "" { if real, err2 := filepath.EvalSymlinks(exe); err2 == nil && real != "" { exe = real } candidates = append(candidates, exe) + binaryPath = exe } if which, err := exec.LookPath("kernel"); err == nil && which != "" { candidates = append(candidates, which) + if binaryPath == "" { + binaryPath = which + } } // Helpers @@ -268,25 +285,21 @@ func SuggestUpgradeCommand() string { type rule struct { check func(string) bool envKeys []string - cmd string + method InstallMethod } rules := []rule{ - {hasHomebrew, nil, ""}, // Homebrew handled specially below - {hasBun, []string{"BUN_INSTALL"}, "bun add -g @onkernel/cli@latest"}, - {hasPNPM, []string{"PNPM_HOME"}, "pnpm add -g @onkernel/cli@latest"}, - {hasNPM, []string{"NPM_CONFIG_PREFIX", "npm_config_prefix", "VOLTA_HOME"}, "npm i -g @onkernel/cli@latest"}, + {hasHomebrew, nil, InstallMethodBrew}, + {hasBun, []string{"BUN_INSTALL"}, InstallMethodBun}, + {hasPNPM, []string{"PNPM_HOME"}, InstallMethodPNPM}, + {hasNPM, []string{"NPM_CONFIG_PREFIX", "npm_config_prefix", "VOLTA_HOME"}, InstallMethodNPM}, } // Path-based detection first for _, c := range candidates { for _, r := range rules { if r.check != nil && r.check(c) { - if r.cmd == "" { - // Homebrew detected, check which tap - return suggestHomebrewCommand(c) - } - return r.cmd + return r.method, binaryPath } } } @@ -305,55 +318,31 @@ func SuggestUpgradeCommand() string { } for _, r := range rules { if len(r.envKeys) > 0 && envSet(r.envKeys) { - return r.cmd + return r.method, binaryPath } } - // Default suggestion when unknown - return "brew upgrade kernel/tap/kernel" -} - -// suggestHomebrewCommand returns the appropriate brew command based on which tap -// the user has installed. If they have the old onkernel/tap, they need to uninstall -// and reinstall from the new kernel/tap. -func suggestHomebrewCommand(exePath string) string { - // Check if the executable path indicates the old tap by looking at version. - // The Cellar path format is: /opt/homebrew/Cellar/kernel//bin/kernel - // Versions before 0.13.0 were published to onkernel/tap, 0.13.0+ to kernel/tap. - if isOldTapVersion(exePath) { - return "brew uninstall kernel && brew install kernel/tap/kernel" - } - - return "brew upgrade kernel/tap/kernel" + return InstallMethodUnknown, binaryPath } -// isOldTapVersion checks if the Homebrew Cellar path contains a version < 0.13.0, -// which indicates it was installed from the old onkernel/tap. -func isOldTapVersion(exePath string) bool { - // Expected path format: .../Cellar/kernel//... - normPath := strings.ToLower(filepath.ToSlash(exePath)) - if !strings.Contains(normPath, "/cellar/kernel/") { - return false - } - - // Extract version from path - parts := strings.Split(normPath, "/cellar/kernel/") - if len(parts) < 2 { - return false - } - remainder := parts[1] // e.g., "0.12.4/bin/kernel" - versionPart := strings.Split(remainder, "/")[0] - if versionPart == "" { - return false - } - - // Parse and compare versions - installed, err := semver.NewVersion(versionPart) - if err != nil { - return false +// SuggestUpgradeCommand attempts to infer how the user installed kernel and +// returns a tailored upgrade command. Falls back to default brew command on unknown. +func SuggestUpgradeCommand() string { + method, _ := DetectInstallMethod() + + switch method { + case InstallMethodBrew: + return "brew upgrade kernel/tap/kernel" + case InstallMethodPNPM: + return "pnpm add -g @onkernel/cli@latest" + case InstallMethodNPM: + return "npm i -g @onkernel/cli@latest" + case InstallMethodBun: + return "bun add -g @onkernel/cli@latest" + default: + // Default suggestion when unknown + return "brew upgrade kernel/tap/kernel" } - threshold, _ := semver.NewVersion("0.13.0") - return installed.LessThan(threshold) } // invokedTrivialCommand returns true if the argv suggests a trivial invocation