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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down
30 changes: 27 additions & 3 deletions internal/cmd/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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),
Expand All @@ -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 {
Expand Down
50 changes: 50 additions & 0 deletions internal/cmd/doctor_version_internal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
9 changes: 9 additions & 0 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 143 additions & 0 deletions internal/cmd/update_notice.go
Original file line number Diff line number Diff line change
@@ -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)))
}
Loading
Loading