From 115a1d5c796215b3a210485ab7929e1c83872081 Mon Sep 17 00:00:00 2001 From: cjr <12208+cjroebuck@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:40:56 +0100 Subject: [PATCH] feat(cli): tell interactive users when a newer release exists Once a day, text-mode terminal runs check GitHub releases and print a one-line stderr notice pointing at `urlbox upgrade`. JSON, quiet, --jq, piped and CI runs are unchanged; URLBOX_NO_UPDATE_NOTIFIER=1 opts out. `urlbox upgrade` now checks first: a no-op when current, and it names the target version otherwise. `doctor` reports available updates as a warning. Also follows the PATH symlink so Intel Homebrew installs are detected, and treats git-describe builds as unversioned. README: list the curl installer, now served at cli.urlbox.com. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01EseBeLTnUNujV1szk76ttH --- CHANGELOG.md | 23 +++ README.md | 17 +- internal/cmd/doctor.go | 30 +++- internal/cmd/doctor_version_internal_test.go | 50 ++++++ internal/cmd/root.go | 9 + internal/cmd/update_notice.go | 143 +++++++++++++++ internal/cmd/update_notice_test.go | 172 +++++++++++++++++++ internal/cmd/upgrade.go | 160 ++++++++++++----- internal/cmd/upgrade_check_test.go | 121 +++++++++++++ internal/update/update.go | 168 ++++++++++++++++++ internal/update/update_test.go | 156 +++++++++++++++++ skills/SKILL.md | 2 +- 12 files changed, 1001 insertions(+), 50 deletions(-) create mode 100644 internal/cmd/doctor_version_internal_test.go create mode 100644 internal/cmd/update_notice.go create mode 100644 internal/cmd/update_notice_test.go create mode 100644 internal/cmd/upgrade_check_test.go create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index bd2729e..732d4e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,29 @@ All notable changes to the `urlbox` CLI are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [SemVer](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added +- **New-version notice.** Once a day, interactive runs check GitHub for a + newer release and print a one-line notice to stderr pointing at + `urlbox upgrade`. Text output to a terminal only: JSON, quiet, `--jq`, + piped and `CI` runs are unchanged. The check runs alongside the command, + is capped at 2s, and a failure is silent. Opt out with + `URLBOX_NO_UPDATE_NOTIFIER=1`. +- `urlbox doctor`'s `version` check reports whether a newer release + exists (`warn`, never `fail`; a failed lookup stays `ok`). + +### Changed +- `urlbox upgrade` checks for the latest release first: it does nothing + when you're already current, and names the target version when it + upgrades. The envelope gains `latestVersion` and `upToDate` (both + omitted when the lookup fails, which never blocks the upgrade). + +### Fixed +- `urlbox upgrade` now follows the PATH symlink to the real binary, so + Intel-Mac Homebrew installs (`/usr/local/bin/urlbox` → `Cellar`) are + detected as Homebrew instead of printing manual instructions. + ## v1.2.0 — 2026-08-19 **`urlbox login` is the only interactive sign-in; `urlbox auth` is gone.** diff --git a/README.md b/README.md index da371f1..b2d9bf3 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,12 @@ scoop install urlbox # Go go install github.com/urlbox/urlbox-cli/cmd/urlbox@latest + +# macOS / Linux, no package manager (installs to /usr/local/bin) +curl -fsSL https://cli.urlbox.com/install.sh | sh ``` -Linux `.deb`/`.rpm`/`.apk` packages and a `curl | sh` installer are covered in [the install docs](https://urlbox.com/docs/cli/install). +The install script checks the download's SHA-256 against the release checksums, and also verifies their Sigstore signature when `cosign` is installed. Linux `.deb`/`.rpm`/`.apk` packages are covered in [the install docs](https://urlbox.com/docs/cli/install). Confirm it worked: @@ -169,9 +172,19 @@ Secrets are masked by default in both text and JSON — pass `--reveal` on `list | `skill show` / `install` | Print or install the agent skill (see below) | | `doctor` | Check version, config, session, credentials, and API reachability | | `dashboard` | Open the Urlbox dashboard in your browser | -| `upgrade` | Update to the latest version via the detected install method | +| `upgrade` | Update to the latest release via the detected install method (no-op when already current) | | `version` | Print the version, commit, and build date | +### Staying up to date + +Once a day, when you run a command in a terminal, the CLI checks GitHub for a newer release and prints one line to stderr if there is one: + +``` +A new version of urlbox is available: 1.2.0 → 1.3.0. Run `urlbox upgrade` to update. +``` + +It never prints in JSON, quiet, `--jq`, or piped output, and never runs when `CI` is set. `urlbox doctor` reports the same check. To turn the notice off, set `URLBOX_NO_UPDATE_NOTIFIER=1`. + Troubleshooting guide: [urlbox.com/docs/cli/troubleshooting](https://urlbox.com/docs/cli/troubleshooting). Full reference: [urlbox.com/docs/cli/command-reference](https://urlbox.com/docs/cli/command-reference). ## Output diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index cd255cd..b5b5a0b 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -17,6 +17,7 @@ import ( "github.com/urlbox/urlbox-cli/internal/api" "github.com/urlbox/urlbox-cli/internal/config" "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/update" "github.com/urlbox/urlbox-cli/internal/version" ) @@ -232,7 +233,7 @@ func runDoctorChecks(ctx context.Context, resolved *config.Resolved, profile *co credentialOnly := resolved != nil && resolved.APISecret != "" return []Check{ - checkVersion(), + checkVersion(ctx), checkInstallMethod(), checkConfigFile(), checkSession(ctx, host, profile, credentialOnly), @@ -244,8 +245,31 @@ func runDoctorChecks(ctx context.Context, resolved *config.Resolved, profile *co } } -func checkVersion() Check { - return Check{Name: "version", Status: "ok", Message: version.Version} +// checkVersion reports the running version and whether a newer release +// exists. A newer release is a warning, never a failure, and a failed +// lookup stays "ok": doctor is a CI health gate and must not flap on +// GitHub rate limits. +func checkVersion(ctx context.Context) Check { + current := updateCurrentVersion() + if !update.IsRelease(current) { + return Check{Name: "version", Status: "ok", Message: current} + } + ctx, cancel := context.WithTimeout(ctx, upgradeCheckTimeout) + defer cancel() + latest, err := updateFetchLatest(ctx) + switch { + case err != nil: + return Check{Name: "version", Status: "ok", Message: current + " (couldn't check for updates)"} + case update.IsNewer(current, latest): + return Check{ + Name: "version", + Status: "warn", + Message: current + " (" + latest + " available)", + Hint: "Run `urlbox upgrade` to update.", + } + default: + return Check{Name: "version", Status: "ok", Message: current + " (latest)"} + } } func checkInstallMethod() Check { diff --git a/internal/cmd/doctor_version_internal_test.go b/internal/cmd/doctor_version_internal_test.go new file mode 100644 index 0000000..ae907f2 --- /dev/null +++ b/internal/cmd/doctor_version_internal_test.go @@ -0,0 +1,50 @@ +package cmd + +import ( + "context" + "errors" + "strings" + "testing" +) + +func withRelease(t *testing.T, current, latest string, err error) { + t.Helper() + SetUpdateCheckForTest(current, func(context.Context) (string, error) { return latest, err }) + t.Cleanup(ResetUpdateCheckForTest) +} + +func TestCheckVersion_NewerReleaseWarns(t *testing.T) { + withRelease(t, "1.2.0", "1.3.0", nil) + c := checkVersion(context.Background()) + if c.Status != "warn" || !strings.Contains(c.Message, "1.3.0 available") || !strings.Contains(c.Hint, "urlbox upgrade") { + t.Errorf("got %+v", c) + } +} + +func TestCheckVersion_CurrentIsOK(t *testing.T) { + withRelease(t, "1.3.0", "1.3.0", nil) + c := checkVersion(context.Background()) + if c.Status != "ok" || c.Message != "1.3.0 (latest)" { + t.Errorf("got %+v", c) + } +} + +// A failed lookup must not turn a healthy install into a warning — doctor +// is used as a CI health gate. +func TestCheckVersion_LookupFailsStaysOK(t *testing.T) { + withRelease(t, "1.2.0", "", errors.New("offline")) + c := checkVersion(context.Background()) + if c.Status != "ok" || !strings.Contains(c.Message, "couldn't check for updates") { + t.Errorf("got %+v", c) + } +} + +func TestCheckVersion_DevBuildSkipsLookup(t *testing.T) { + called := false + SetUpdateCheckForTest("dev", func(context.Context) (string, error) { called = true; return "1.3.0", nil }) + t.Cleanup(ResetUpdateCheckForTest) + c := checkVersion(context.Background()) + if called || c.Status != "ok" || c.Message != "dev" { + t.Errorf("called=%v check=%+v", called, c) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index a011811..b15224f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -38,7 +38,16 @@ func Execute(args []string, stdout, stderr io.Writer) int { rootCmd.SetArgs(args) rootCmd.SetOut(stdout) rootCmd.SetErr(stderr) + notifier := attachUpdateNotifier(rootCmd) + code := executeRoot(rootCmd, args, stdout, stderr) + notifier.finish(stderr) + return code +} + +// executeRoot runs the command tree and writes any error envelope, +// returning the process exit code. +func executeRoot(rootCmd *cobra.Command, args []string, stdout, stderr io.Writer) int { err := rootCmd.Execute() if err == nil { return 0 diff --git a/internal/cmd/update_notice.go b/internal/cmd/update_notice.go new file mode 100644 index 0000000..d4a8612 --- /dev/null +++ b/internal/cmd/update_notice.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "context" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/urlbox/urlbox-cli/internal/output" + "github.com/urlbox/urlbox-cli/internal/update" + "github.com/urlbox/urlbox-cli/internal/version" +) + +// updateCheckTimeout bounds the once-a-day release lookup. The check runs +// alongside the command, so this only adds latency when the command itself +// finishes sooner. +const updateCheckTimeout = 2 * time.Second + +var ( + updateCurrentVersion = func() string { return version.Version } + updateFetchLatest = func(ctx context.Context) (string, error) { + return update.FetchLatest(ctx, update.LatestReleaseURL) + } +) + +// SetUpdateCheckForTest pins the running version and the release source. +// Pair with t.Cleanup(ResetUpdateCheckForTest). +func SetUpdateCheckForTest(current string, fetch func(context.Context) (string, error)) { + updateCurrentVersion = func() string { return current } + updateFetchLatest = fetch +} + +// ResetUpdateCheckForTest restores the build version and the GitHub source. +func ResetUpdateCheckForTest() { + updateCurrentVersion = func() string { return version.Version } + updateFetchLatest = func(ctx context.Context) (string, error) { + return update.FetchLatest(ctx, update.LatestReleaseURL) + } +} + +type fetchResult struct { + latest string + err error +} + +// updateNotifier tells interactive users when a newer release exists. It +// starts from the root PersistentPreRunE (flags are parsed by then) and +// prints after the command has written its own output. +type updateNotifier struct { + started bool + state update.State + result chan fetchResult + cancel context.CancelFunc +} + +// attachUpdateNotifier chains the notifier's start onto root's existing +// PersistentPreRunE. No subcommand defines its own, so this runs for every +// command that gets as far as executing. +func attachUpdateNotifier(root *cobra.Command) *updateNotifier { + n := &updateNotifier{} + pre := root.PersistentPreRunE + root.PersistentPreRunE = func(c *cobra.Command, args []string) error { + if pre != nil { + if err := pre(c, args); err != nil { + return err + } + } + n.start(c) + return nil + } + return n +} + +// updateNoticeAllowed is the same bar as the post-render report hint — text +// output to a human terminal only — plus opt-outs. JSON, quiet, --jq and +// piped runs stay byte-identical for agents and scripts. +func updateNoticeAllowed(c *cobra.Command) bool { + if os.Getenv("URLBOX_NO_UPDATE_NOTIFIER") != "" || os.Getenv("CI") != "" { + return false + } + switch c.Name() { + case "upgrade", "__complete", "__completeNoDesc", "completion": + return false // upgrade runs its own check; completion output is machine-read + } + root := c.Root() + if jq, _ := root.PersistentFlags().GetString("jq"); jq != "" { + return false + } + formatFlag, _ := root.PersistentFlags().GetString("output-format") + if output.ResolveFormat(formatFlag, c.OutOrStdout()) != output.FormatText { + return false + } + if !isStderrTTY(c.ErrOrStderr()) { + return false + } + return update.IsRelease(updateCurrentVersion()) +} + +func (n *updateNotifier) start(c *cobra.Command) { + if !updateNoticeAllowed(c) { + return + } + n.started = true + n.state = update.LoadState(update.StatePath()) + if !n.state.Stale(time.Now()) { + return + } + ctx, cancel := context.WithTimeout(context.Background(), updateCheckTimeout) + n.cancel = cancel + n.result = make(chan fetchResult, 1) + go func() { + latest, err := updateFetchLatest(ctx) + n.result <- fetchResult{latest, err} + }() +} + +// finish waits for an in-flight check (bounded by updateCheckTimeout), +// records it, and prints the notice when a newer release exists. A failed +// check is silent and still recorded, so an offline machine waits a day +// before trying again. +func (n *updateNotifier) finish(stderr io.Writer) { + if !n.started { + return + } + latest := n.state.Latest + if n.result != nil { + res := <-n.result + n.cancel() + if res.err == nil { + latest = res.latest + } + _ = update.SaveState(update.StatePath(), update.State{CheckedAt: time.Now(), Latest: latest}) + } + current := updateCurrentVersion() + if !update.IsNewer(current, latest) { + return + } + styles := output.NewStylesForWriter(stderr) + _, _ = fmt.Fprintln(stderr, styles.Muted.Render(update.Notice(current, latest))) +} diff --git a/internal/cmd/update_notice_test.go b/internal/cmd/update_notice_test.go new file mode 100644 index 0000000..0569821 --- /dev/null +++ b/internal/cmd/update_notice_test.go @@ -0,0 +1,172 @@ +package cmd_test + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/cmd" + "github.com/urlbox/urlbox-cli/internal/update" +) + +const noticeFragment = "A new version of urlbox is available" + +// fakeRelease counts calls so tests can assert when the network is (not) hit. +type fakeRelease struct { + latest string + err error + calls int +} + +func (f *fakeRelease) fetch(context.Context) (string, error) { + f.calls++ + return f.latest, f.err +} + +// setupUpdateNotice isolates the config dir, forces an interactive stderr, +// clears the opt-out env vars (CI sets CI=true), and pins the running +// version to 1.2.0 with a fake release source. +func setupUpdateNotice(t *testing.T, rel *fakeRelease) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("CI", "") + t.Setenv("URLBOX_NO_UPDATE_NOTIFIER", "") + cmd.SetStderrTTYForTest(true) + t.Cleanup(cmd.ResetStderrTTYForTest) + cmd.SetUpdateCheckForTest("1.2.0", rel.fetch) + t.Cleanup(cmd.ResetUpdateCheckForTest) + return filepath.Join(dir, "urlbox", "update-check.json") +} + +func runVersionText(t *testing.T) (stdout, stderr string, code int) { + t.Helper() + var out, errb bytes.Buffer + code = cmd.Execute([]string{"version", "--output-format", "text"}, &out, &errb) + return out.String(), errb.String(), code +} + +func TestUpdateNotice_StaleCache_FetchesAndPrintsToStderr(t *testing.T) { + rel := &fakeRelease{latest: "1.3.0"} + statePath := setupUpdateNotice(t, rel) + + stdout, stderr, code := runVersionText(t) + + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if rel.calls != 1 { + t.Errorf("fetch calls = %d, want 1", rel.calls) + } + if !strings.Contains(stderr, "1.2.0 → 1.3.0") || !strings.Contains(stderr, "urlbox upgrade") { + t.Errorf("stderr missing notice: %q", stderr) + } + if strings.Contains(stdout, noticeFragment) { + t.Errorf("notice leaked onto stdout: %q", stdout) + } + if s := update.LoadState(statePath); s.Latest != "1.3.0" || s.CheckedAt.IsZero() { + t.Errorf("cache not written: %+v", s) + } +} + +func TestUpdateNotice_FreshCache_NoFetchStillNotifies(t *testing.T) { + rel := &fakeRelease{latest: "9.9.9"} + statePath := setupUpdateNotice(t, rel) + if err := update.SaveState(statePath, update.State{CheckedAt: time.Now().Add(-time.Hour), Latest: "1.3.0"}); err != nil { + t.Fatal(err) + } + + _, stderr, _ := runVersionText(t) + + if rel.calls != 0 { + t.Errorf("fresh cache must not hit the network; calls = %d", rel.calls) + } + if !strings.Contains(stderr, "1.2.0 → 1.3.0") { + t.Errorf("expected notice from cache, got %q", stderr) + } +} + +func TestUpdateNotice_UpToDate_Silent(t *testing.T) { + rel := &fakeRelease{latest: "1.2.0"} + setupUpdateNotice(t, rel) + + _, stderr, _ := runVersionText(t) + + if strings.Contains(stderr, noticeFragment) { + t.Errorf("no notice expected when current: %q", stderr) + } +} + +func TestUpdateNotice_FetchFails_SilentAndBacksOff(t *testing.T) { + rel := &fakeRelease{err: errors.New("offline")} + statePath := setupUpdateNotice(t, rel) + + _, stderr, code := runVersionText(t) + + if code != 0 { + t.Fatalf("a failed check must never fail the command; exit %d", code) + } + if strings.Contains(stderr, noticeFragment) || strings.Contains(stderr, "offline") { + t.Errorf("failed check must be silent: %q", stderr) + } + // The attempt is recorded so an offline machine doesn't retry every run. + if s := update.LoadState(statePath); s.CheckedAt.IsZero() { + t.Error("failed check should still record CheckedAt") + } +} + +func TestUpdateNotice_Gates(t *testing.T) { + cases := []struct { + name string + args []string + setup func(t *testing.T) + }{ + {"json output", []string{"version", "--output-format", "json"}, nil}, + {"quiet output", []string{"version", "--output-format", "quiet"}, nil}, + {"jq", []string{"version", "--output-format", "text", "--jq", ".data"}, nil}, + {"stdout piped, no explicit format", []string{"version"}, nil}, + {"stderr not a tty", []string{"version", "--output-format", "text"}, func(*testing.T) { cmd.SetStderrTTYForTest(false) }}, + {"opt-out env", []string{"version", "--output-format", "text"}, func(t *testing.T) { t.Setenv("URLBOX_NO_UPDATE_NOTIFIER", "1") }}, + {"CI env", []string{"version", "--output-format", "text"}, func(t *testing.T) { t.Setenv("CI", "true") }}, + {"dev build", []string{"version", "--output-format", "text"}, func(*testing.T) { cmd.SetUpdateCheckForTest("dev", (&fakeRelease{latest: "1.3.0"}).fetch) }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rel := &fakeRelease{latest: "1.3.0"} + setupUpdateNotice(t, rel) + if c.setup != nil { + c.setup(t) + } + var out, errb bytes.Buffer + cmd.Execute(c.args, &out, &errb) + if rel.calls != 0 { + t.Errorf("gated run must not hit the network; calls = %d", rel.calls) + } + if strings.Contains(out.String()+errb.String(), noticeFragment) { + t.Errorf("gated run printed the notice: stdout=%q stderr=%q", out.String(), errb.String()) + } + }) + } +} + +func TestUpdateNotice_PrintedAfterFailedCommand(t *testing.T) { + rel := &fakeRelease{latest: "1.3.0"} + setupUpdateNotice(t, rel) + + var out, errb bytes.Buffer + code := cmd.Execute([]string{"config", "get", "no_such_key", "--output-format", "text"}, &out, &errb) + + if code == 0 { + t.Fatal("expected the command itself to fail") + } + stderr := errb.String() + errAt := strings.Index(stderr, "Error") + noticeAt := strings.Index(stderr, noticeFragment) + if errAt < 0 || noticeAt < 0 || noticeAt < errAt { + t.Errorf("notice should follow the error line: %q", stderr) + } +} diff --git a/internal/cmd/upgrade.go b/internal/cmd/upgrade.go index 681abad..cfb3414 100644 --- a/internal/cmd/upgrade.go +++ b/internal/cmd/upgrade.go @@ -6,11 +6,13 @@ import ( "io" "os" "os/exec" + "path/filepath" "strings" + "time" "github.com/spf13/cobra" "github.com/urlbox/urlbox-cli/internal/output" - "github.com/urlbox/urlbox-cli/internal/version" + "github.com/urlbox/urlbox-cli/internal/update" ) // ExecFunc runs an external command, writing output to w. @@ -41,7 +43,7 @@ func DetectInstallMethod(binaryPath string) string { func RunUpgrade(stderr io.Writer, execPath string, runner ExecFunc) error { method := DetectInstallMethod(execPath) - _, _ = fmt.Fprintf(stderr, "Current version: %s\n", version.Version) + _, _ = fmt.Fprintf(stderr, "Current version: %s\n", updateCurrentVersion()) _, _ = fmt.Fprintf(stderr, "Install method: %s\n", method) _, _ = fmt.Fprintf(stderr, "Binary path: %s\n\n", execPath) @@ -75,56 +77,108 @@ func newUpgradeCmd(stdout, stderr io.Writer) *cobra.Command { return &cobra.Command{ Use: "upgrade", Short: "Update urlbox to the latest version", - Long: "Detects how urlbox was installed and runs the appropriate update command.", + Long: `Checks for a newer release, then detects how urlbox was installed and runs +the matching update command (brew, scoop, npm or go install). Does nothing +when you already have the latest release. + +Exit codes: + 0 upgraded, already up to date, or manual instructions printed + 10 the package manager failed (its output is on stderr)`, RunE: func(cmd *cobra.Command, args []string) error { - execPath, err := os.Executable() - if err != nil { - return output.NewCLIError( - output.ErrServer, - "could not determine binary path: "+err.Error(), - "This is a CLI bug — please report at https://github.com/urlbox/urlbox-cli/issues.", - ) - } - - // Run the upgrade first — it streams human progress to stderr. - // On failure we surface a CLIError; on success we emit the - // standard envelope on stdout so agents get a structured - // description of what happened. - if err := RunUpgrade(cmd.ErrOrStderr(), execPath, runExternal); err != nil { - return output.NewCLIError( - output.ErrServer, - "upgrade failed: "+err.Error(), - "Re-run the package-manager command shown on stderr manually, or check https://github.com/urlbox/urlbox-cli/issues.", - ) - } - - method := DetectInstallMethod(execPath) - env := output.NewEnvelope("upgrade", map[string]any{ - "currentVersion": version.Version, - "installMethod": method, - "binaryPath": execPath, - }, upgradeSummary(method), []output.Breadcrumb{ - {Action: "verify", Cmd: "urlbox --version"}, - }) - return writeEnvelope(cmd, env) + return runUpgradeCmd(cmd) }, } } +// upgradeCheckTimeout bounds the release lookup before an upgrade. Longer +// than the post-command notice's budget: the user asked for this one. +const upgradeCheckTimeout = 5 * time.Second + +func runUpgradeCmd(cmd *cobra.Command) error { + stderr := cmd.ErrOrStderr() + execPath, err := upgradeExecPath() + if err != nil { + return output.NewCLIError( + output.ErrServer, + "could not determine binary path: "+err.Error(), + "This is a CLI bug — please report at https://github.com/urlbox/urlbox-cli/issues.", + ) + } + // Package managers install a symlink on PATH (Intel Homebrew: + // /usr/local/bin/urlbox -> ../Cellar/...). The real path is what + // identifies the install method. + if resolved, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolved + } + + current := updateCurrentVersion() + latest := latestForUpgrade(stderr, current) + method := DetectInstallMethod(execPath) + data := map[string]any{ + "currentVersion": current, + "installMethod": method, + "binaryPath": execPath, + } + breadcrumbs := []output.Breadcrumb{{Action: "verify", Cmd: "urlbox --version"}} + if latest != "" { + data["latestVersion"] = latest + data["upToDate"] = !update.IsNewer(current, latest) + if !update.IsNewer(current, latest) { + _, _ = fmt.Fprintf(stderr, "urlbox %s is already up to date.\n", current) + return writeEnvelope(cmd, output.NewEnvelope("upgrade", data, + "Already up to date ("+current+")", breadcrumbs)) + } + _, _ = fmt.Fprintf(stderr, "Upgrading urlbox %s → %s\n\n", current, latest) + } + + // Run the upgrade first — it streams human progress to stderr. + // On failure we surface a CLIError; on success we emit the + // standard envelope on stdout so agents get a structured + // description of what happened. + if err := RunUpgrade(stderr, execPath, upgradeRunner); err != nil { + return output.NewCLIError( + output.ErrServer, + "upgrade failed: "+err.Error(), + "Re-run the package-manager command shown on stderr manually, or check https://github.com/urlbox/urlbox-cli/issues.", + ) + } + return writeEnvelope(cmd, output.NewEnvelope("upgrade", data, + upgradeSummary(method, current, latest), breadcrumbs)) +} + +// latestForUpgrade looks up the newest release. Returns "" for local dev +// builds (nothing to compare) and when the lookup fails — a failed check +// never blocks the upgrade itself. +func latestForUpgrade(stderr io.Writer, current string) string { + if !update.IsRelease(current) { + return "" + } + ctx, cancel := context.WithTimeout(context.Background(), upgradeCheckTimeout) + defer cancel() + latest, err := updateFetchLatest(ctx) + if err != nil { + _, _ = fmt.Fprintf(stderr, "Couldn't check for the latest version (%v); upgrading anyway.\n\n", err) + return "" + } + return latest +} + // upgradeSummary returns a human-friendly one-liner describing what the // upgrade command just did, for the envelope's `summary` field. -func upgradeSummary(method string) string { - switch method { - case "brew": - return "Upgraded via Homebrew" - case "scoop": - return "Upgraded via Scoop" - case "npm": - return "Upgraded via npm" - case "go": - return "Upgraded via go install" - default: +func upgradeSummary(method, current, latest string) string { + via := map[string]string{ + "brew": "Homebrew", + "scoop": "Scoop", + "npm": "npm", + "go": "go install", + }[method] + switch { + case via == "": return "Manual upgrade instructions printed to stderr" + case latest != "": + return fmt.Sprintf("Upgraded %s → %s via %s", current, latest, via) + default: + return "Upgraded via " + via } } @@ -134,3 +188,21 @@ func runExternal(w io.Writer, name string, args ...string) error { c.Stderr = w return c.Run() } + +// SetUpgradeForTest pins the binary path and package-manager runner. +// Pair with t.Cleanup(ResetUpgradeForTest). +func SetUpgradeForTest(execPath string, runner ExecFunc) { + upgradeExecPath = func() (string, error) { return execPath, nil } + upgradeRunner = runner +} + +// ResetUpgradeForTest restores os.Executable and the real runner. +func ResetUpgradeForTest() { + upgradeExecPath = os.Executable + upgradeRunner = runExternal +} + +var ( + upgradeExecPath = os.Executable + upgradeRunner ExecFunc = runExternal +) diff --git a/internal/cmd/upgrade_check_test.go b/internal/cmd/upgrade_check_test.go new file mode 100644 index 0000000..3b6a659 --- /dev/null +++ b/internal/cmd/upgrade_check_test.go @@ -0,0 +1,121 @@ +package cmd_test + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/urlbox/urlbox-cli/internal/cmd" +) + +// runUpgradeJSON runs `urlbox upgrade` as if installed at execPath, with +// the given release answer, and decodes the JSON envelope. +func runUpgradeJSON(t *testing.T, execPath string, rel *fakeRelease, fake *fakeExec) (env map[string]any, stderr string, code int) { + t.Helper() + cmd.SetUpdateCheckForTest("1.2.0", rel.fetch) + t.Cleanup(cmd.ResetUpdateCheckForTest) + cmd.SetUpgradeForTest(execPath, fake.run) + t.Cleanup(cmd.ResetUpgradeForTest) + + var out, errb bytes.Buffer + code = cmd.Execute([]string{"upgrade", "--output-format", "json"}, &out, &errb) + if err := json.Unmarshal(out.Bytes(), &env); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, out.String()) + } + return env, errb.String(), code +} + +func TestUpgrade_AlreadyUpToDate_SkipsPackageManager(t *testing.T) { + fake := &fakeExec{} + env, stderr, code := runUpgradeJSON(t, "/opt/homebrew/bin/urlbox", &fakeRelease{latest: "1.2.0"}, fake) + + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if fake.name != "" { + t.Errorf("package manager must not run when current; ran %q %v", fake.name, fake.args) + } + data, _ := env["data"].(map[string]any) + if data["upToDate"] != true || data["latestVersion"] != "1.2.0" { + t.Errorf("data = %v", data) + } + if !strings.Contains(stderr, "already up to date") { + t.Errorf("stderr should say it's up to date: %q", stderr) + } +} + +func TestUpgrade_NewerRelease_RunsPackageManagerAndNamesTarget(t *testing.T) { + fake := &fakeExec{} + env, stderr, code := runUpgradeJSON(t, "/opt/homebrew/bin/urlbox", &fakeRelease{latest: "1.3.0"}, fake) + + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if fake.name != "brew" { + t.Errorf("expected brew to run, got %q", fake.name) + } + data, _ := env["data"].(map[string]any) + if data["upToDate"] != false || data["latestVersion"] != "1.3.0" || data["currentVersion"] != "1.2.0" { + t.Errorf("data = %v", data) + } + if summary, _ := env["summary"].(string); summary != "Upgraded 1.2.0 → 1.3.0 via Homebrew" { + t.Errorf("summary = %q", summary) + } + if !strings.Contains(stderr, "1.2.0 → 1.3.0") { + t.Errorf("stderr should name the target version: %q", stderr) + } +} + +func TestUpgrade_CheckFails_StillUpgrades(t *testing.T) { + fake := &fakeExec{} + env, stderr, code := runUpgradeJSON(t, "/opt/homebrew/bin/urlbox", &fakeRelease{err: errors.New("rate limited")}, fake) + + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if fake.name != "brew" { + t.Errorf("a failed check must not block the upgrade; ran %q", fake.name) + } + if !strings.Contains(stderr, "Couldn't check for the latest version") { + t.Errorf("stderr should mention the failed check: %q", stderr) + } + data, _ := env["data"].(map[string]any) + if _, ok := data["latestVersion"]; ok { + t.Errorf("latestVersion should be absent when unknown: %v", data) + } + if summary, _ := env["summary"].(string); summary != "Upgraded via Homebrew" { + t.Errorf("summary = %q", summary) + } +} + +func TestUpgrade_FollowsSymlinkToDetectInstallMethod(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "Cellar", "urlbox", "1.2.0", "bin", "urlbox") + if err := os.MkdirAll(filepath.Dir(target), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("bin"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "bin", "urlbox") // Intel Homebrew: /usr/local/bin/urlbox → Cellar + if err := os.MkdirAll(filepath.Dir(link), 0o750); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + fake := &fakeExec{} + _, stderr, code := runUpgradeJSON(t, link, &fakeRelease{latest: "1.3.0"}, fake) + + if code != 0 { + t.Fatalf("exit %d, stderr: %s", code, stderr) + } + if fake.name != "brew" { + t.Errorf("symlinked Homebrew install should upgrade via brew; ran %q", fake.name) + } +} diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000..f5b506d --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,168 @@ +// Package update answers "is there a newer urlbox release?" for the +// post-command notice, `urlbox upgrade`, and `urlbox doctor`. +// +// The latest release comes from the GitHub releases API — the same source +// every installer (npm, Homebrew, Scoop, install.sh) downloads from. The +// answer is cached in the config directory for 24h so the check costs at +// most one request per day. +package update + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + + "github.com/urlbox/urlbox-cli/internal/api" + "github.com/urlbox/urlbox-cli/internal/config" + "github.com/urlbox/urlbox-cli/internal/version" +) + +// LatestReleaseURL is the GitHub API endpoint for the newest non-prerelease. +const LatestReleaseURL = "https://api.github.com/repos/urlbox/urlbox-cli/releases/latest" + +// CheckInterval is how long a cached answer stays fresh. +const CheckInterval = 24 * time.Hour + +// State is the cached result of the last check. +type State struct { + CheckedAt time.Time `json:"checked_at"` + Latest string `json:"latest_version,omitempty"` +} + +// Stale reports whether the cached answer should be refreshed. A timestamp +// in the future (clock skew, a restored backup) counts as stale. +func (s State) Stale(now time.Time) bool { + if s.CheckedAt.IsZero() || s.CheckedAt.After(now) { + return true + } + return now.Sub(s.CheckedAt) >= CheckInterval +} + +// StatePath is the cache file, next to config.json. +func StatePath() string { + return filepath.Join(filepath.Dir(config.Path()), "update-check.json") +} + +// LoadState reads the cache. A missing or unreadable file is a zero State, +// which is always stale. +func LoadState(path string) State { + var s State + b, err := os.ReadFile(path) //nolint:gosec // path is the CLI's own cache file + if err != nil { + return State{} + } + if err := json.Unmarshal(b, &s); err != nil { + return State{} + } + return s +} + +// SaveState writes the cache (0600, parent dir created if needed). +func SaveState(path string, s State) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return config.SafeWriteUserFile(path, b, config.SafeWriteOptions{Force: true}) +} + +// FetchLatest returns the newest release's version, without a leading "v". +func FetchLatest(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if err != nil { + return "", err + } + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("User-Agent", api.BuildUserAgent(version.Version)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("release check returned HTTP %d", resp.StatusCode) + } + var body struct { + TagName string `json:"tag_name"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return "", fmt.Errorf("release check: %w", err) + } + if body.TagName == "" { + return "", errors.New("release check: no tag_name in response") + } + return strings.TrimPrefix(body.TagName, "v"), nil +} + +// IsNewer reports whether latest is a newer release than current. Anything +// unparseable (a "dev" build, an empty answer) is never newer, and neither +// is a prerelease: users are only pointed at stable releases. +func IsNewer(current, latest string) bool { + cur, ok := parse(current) + if !ok { + return false + } + lat, ok := parse(latest) + if !ok || lat.pre != "" { + return false + } + for i := range cur.nums { + if lat.nums[i] != cur.nums[i] { + return lat.nums[i] > cur.nums[i] + } + } + // Same x.y.z: a stable release beats the current prerelease of it. + return cur.pre != "" +} + +// IsRelease reports whether v is a comparable release version. Local +// builds report "dev" and are never checked. +func IsRelease(v string) bool { + _, ok := parse(v) + return ok +} + +// Notice is the one-line message shown after a command. +func Notice(current, latest string) string { + return fmt.Sprintf("A new version of urlbox is available: %s → %s. Run `urlbox upgrade` to update.", + strings.TrimPrefix(current, "v"), latest) +} + +type semver struct { + nums [3]int + pre string +} + +func parse(v string) (semver, bool) { + v = strings.TrimPrefix(strings.TrimSpace(v), "v") + core, pre, _ := strings.Cut(v, "-") + parts := strings.Split(core, ".") + if len(parts) != 3 { + return semver{}, false + } + var s semver + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil || n < 0 { + return semver{}, false + } + s.nums[i] = n + } + if gitDescribe.MatchString(pre) { + return semver{}, false // a local build past the tag, not a release + } + s.pre = pre + return s, true +} + +// gitDescribe matches the suffix `git describe --dirty` adds after a tag: +// "-dirty", "-14-g3a164cc" or "-1-gc9d96d6-dirty". +var gitDescribe = regexp.MustCompile(`^(\d+-g[0-9a-f]+(-dirty)?|dirty)$`) diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000..9bc9108 --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,156 @@ +package update_test + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/urlbox/urlbox-cli/internal/update" +) + +func TestIsNewer(t *testing.T) { + cases := []struct { + current, latest string + want bool + }{ + {"1.2.0", "1.3.0", true}, + {"1.2.0", "v1.2.1", true}, + {"v1.2.0", "2.0.0", true}, + {"1.2.0", "1.2.0", false}, + {"1.3.0", "1.2.9", false}, + {"1.10.0", "1.9.0", false}, // numeric, not lexical + {"1.2.0-rc.1", "1.2.0", true}, + {"1.2.0", "1.3.0-rc.1", false}, // never nag towards a prerelease + {"dev", "1.3.0", false}, // unversioned builds never nag + {"1.2.0", "garbage", false}, + {"1.2.0", "", false}, + } + for _, c := range cases { + if got := update.IsNewer(c.current, c.latest); got != c.want { + t.Errorf("IsNewer(%q, %q) = %v, want %v", c.current, c.latest, got, c.want) + } + } +} + +func TestFetchLatest_ReadsTagFromGitHubReleaseShape(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Accept") != "application/vnd.github+json" { + t.Errorf("Accept header = %q", r.Header.Get("Accept")) + } + _, _ = w.Write([]byte(`{"tag_name":"v1.3.0","name":"v1.3.0","prerelease":false}`)) + })) + defer srv.Close() + + got, err := update.FetchLatest(context.Background(), srv.URL) + if err != nil { + t.Fatalf("FetchLatest: %v", err) + } + if got != "1.3.0" { + t.Errorf("FetchLatest = %q, want 1.3.0 (leading v stripped)", got) + } +} + +func TestFetchLatest_Non200IsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) // GitHub's unauthenticated rate limit + })) + defer srv.Close() + + if _, err := update.FetchLatest(context.Background(), srv.URL); err == nil { + t.Fatal("expected an error for a 403") + } +} + +func TestFetchLatest_MissingTagIsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + if _, err := update.FetchLatest(context.Background(), srv.URL); err == nil { + t.Fatal("expected an error when tag_name is absent") + } +} + +func TestState_RoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "urlbox", "update-check.json") + checked := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + + if err := update.SaveState(path, update.State{CheckedAt: checked, Latest: "1.3.0"}); err != nil { + t.Fatalf("SaveState: %v", err) + } + got := update.LoadState(path) + if got.Latest != "1.3.0" || !got.CheckedAt.Equal(checked) { + t.Errorf("LoadState = %+v", got) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("state file mode = %o, want 600", perm) + } +} + +func TestLoadState_MissingOrCorruptIsZero(t *testing.T) { + dir := t.TempDir() + if s := update.LoadState(filepath.Join(dir, "absent.json")); !s.CheckedAt.IsZero() || s.Latest != "" { + t.Errorf("missing file: got %+v", s) + } + bad := filepath.Join(dir, "bad.json") + if err := os.WriteFile(bad, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + if s := update.LoadState(bad); !s.CheckedAt.IsZero() || s.Latest != "" { + t.Errorf("corrupt file: got %+v", s) + } +} + +func TestState_Stale(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + if !(update.State{}).Stale(now) { + t.Error("a never-checked state must be stale") + } + if (update.State{CheckedAt: now.Add(-23 * time.Hour)}).Stale(now) { + t.Error("checked 23h ago must be fresh") + } + if !(update.State{CheckedAt: now.Add(-25 * time.Hour)}).Stale(now) { + t.Error("checked 25h ago must be stale") + } + if !(update.State{CheckedAt: now.Add(time.Hour)}).Stale(now) { + t.Error("a future timestamp (clock skew) must be treated as stale") + } +} + +func TestNotice(t *testing.T) { + got := update.Notice("1.2.0", "1.3.0") + want := "A new version of urlbox is available: 1.2.0 → 1.3.0. Run `urlbox upgrade` to update." + if got != want { + t.Errorf("Notice = %q\nwant %q", got, want) + } +} + +func TestIsRelease(t *testing.T) { + for v, want := range map[string]bool{"1.2.0": true, "v1.2.0": true, "1.2.0-rc.1": true, "dev": false, "": false, "1.2": false} { + if got := update.IsRelease(v); got != want { + t.Errorf("IsRelease(%q) = %v, want %v", v, got, want) + } + } +} + +// `make build` stamps `git describe` output: commits AFTER the tag, not a +// prerelease of it. Those builds must never be told to "upgrade" backwards. +func TestGitDescribeBuildsAreNotReleases(t *testing.T) { + for _, v := range []string{"v1.2.0-1-gc9d96d6-dirty", "v1.2.0-14-g3a164cc", "v1.2.0-dirty"} { + if update.IsRelease(v) { + t.Errorf("IsRelease(%q) = true, want false", v) + } + if update.IsNewer(v, "1.2.0") || update.IsNewer(v, "1.3.0") { + t.Errorf("IsNewer(%q, …) must be false for local builds", v) + } + } +} diff --git a/skills/SKILL.md b/skills/SKILL.md index f8eb4e0..29d57a3 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -180,7 +180,7 @@ documents the well-known options, but the API accepts more. | `urlbox schema render` | Print the JSON Schema for the render request payload | | `urlbox skill` | Show this skill content (`urlbox skill show`) | | `urlbox status ` | Check / poll the status of an async render | -| `urlbox upgrade` | Self-update via detected install method | +| `urlbox upgrade` | Update via detected install method; no-op if current | ## render: capture a URL