feat: add browseros-cli self-updater (#605)

* feat: add browseros-cli self-updater

* fix: address review comments for 0327-cli_self_updater

* fix: address PR review comments for 0327-cli_self_updater

* fix: replace goreleaser with Makefile-based release build

Remove .goreleaser.yml (required Pro license for monorepo field) and
consolidate cross-compilation into `make release`. CI now uses the same
Makefile target, fixing a bug where POSTHOG_API_KEY was missing from
release ldflags.

* fix: address critical self-updater bugs from code review

- Fix SHA256 checksum mismatch: verify archive checksum before extraction
  instead of verifying extracted binary against archive hash (was always
  failing). Add VerifyChecksum() and integration test.
- Fix JSON field name mismatch: TypeScript was emitting camelCase
  (publishedAt, archiveFormat) but Go expected snake_case
  (published_at, archive_format). Manifest parsing was silently broken.
- Add decompression size limit (256 MB) to prevent zip/gzip bombs.
- Don't update LastCheckedAt on transient errors so retry happens on
  next CLI invocation instead of waiting 24h.
This commit is contained in:
Nikhil
2026-03-27 14:52:54 -07:00
committed by GitHub
parent 83a25ad301
commit ace9307878
21 changed files with 2039 additions and 90 deletions

View File

@@ -1,50 +0,0 @@
version: 2
project_name: browseros-cli
monorepo:
tag_prefix: browseros-cli-
builds:
- main: .
binary: browseros-cli
env:
- CGO_ENABLED=0
flags:
- -trimpath
ldflags:
- -s -w -X main.version={{ .Version }} -X browseros-cli/analytics.posthogAPIKey={{ .Env.POSTHOG_API_KEY }}
targets:
- darwin_amd64
- darwin_arm64
- linux_amd64
- linux_arm64
- windows_amd64
- windows_arm64
archives:
- format: tar.gz
format_overrides:
- goos: windows
format: zip
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
files:
- "none*"
checksum:
name_template: checksums.txt
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
- "^ci:"
release:
github:
owner: browseros-ai
name: BrowserOS
prerelease: auto
name_template: "browseros-cli v{{ .Version }}"

View File

@@ -7,13 +7,17 @@ LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$
$(BINARY): $(SOURCES)
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
.PHONY: install clean vet test
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
DIST := dist
.PHONY: install clean vet test release
install:
go install -ldflags "$(LDFLAGS)" .
clean:
rm -f $(BINARY)
rm -rf $(DIST)
vet:
go vet ./...
@@ -21,8 +25,26 @@ vet:
test:
go test -tags integration -v -timeout 120s ./...
release-dry:
goreleaser release --snapshot --clean
release:
goreleaser release --clean
@if [ "$(VERSION)" = "dev" ]; then echo "Error: VERSION required (e.g. make release VERSION=0.1.0)" >&2; exit 1; fi
@rm -rf $(DIST) && mkdir -p $(DIST)
@for pair in $(PLATFORMS); do \
OS=$${pair%/*}; \
ARCH=$${pair#*/}; \
EXT=""; \
if [ "$$OS" = "windows" ]; then EXT=".exe"; fi; \
echo "Building $$OS/$$ARCH..."; \
GOOS=$$OS GOARCH=$$ARCH CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w $(LDFLAGS)" \
-o "$(DIST)/$(BINARY)$$EXT" .; \
ARCHIVE="$(BINARY)_$(VERSION)_$${OS}_$${ARCH}"; \
if [ "$$OS" = "windows" ]; then \
(cd $(DIST) && zip "$${ARCHIVE}.zip" "$(BINARY)$$EXT"); \
else \
(cd $(DIST) && tar czf "$${ARCHIVE}.tar.gz" "$(BINARY)"); \
fi; \
mv "$(DIST)/$(BINARY)$$EXT" "$(DIST)/$(BINARY)_$${OS}_$${ARCH}$$EXT"; \
done
@cd $(DIST) && (command -v sha256sum >/dev/null 2>&1 && sha256sum *.tar.gz *.zip || shasum -a 256 *.tar.gz *.zip) > checksums.txt
@echo "=== Built artifacts ==="
@ls -lh $(DIST)

View File

@@ -54,6 +54,16 @@ browseros-cli init # interactive — prompts for URL
Config is saved to `~/.config/browseros-cli/config.yaml`. The CLI also auto-discovers the server from `~/.browseros/server.json` (written by BrowserOS on startup).
### CLI updates
The CLI checks for a newer BrowserOS CLI release in the background about once per day and will suggest an update on a later run when one is available.
```bash
browseros-cli update # check and apply the latest CLI release
browseros-cli update --check # check only
browseros-cli update --yes # apply without prompting
```
## Usage
```bash

View File

@@ -1,6 +1,7 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -13,6 +14,7 @@ import (
"browseros-cli/config"
"browseros-cli/mcp"
"browseros-cli/output"
"browseros-cli/update"
"github.com/fatih/color"
"github.com/spf13/cobra"
@@ -28,6 +30,8 @@ var (
version = "dev"
)
const automaticUpdateDrainTimeout = 150 * time.Millisecond
func SetVersion(v string) {
version = v
}
@@ -114,11 +118,24 @@ var rootCmd = &cobra.Command{
}
func Execute() {
automaticUpdater := newAutomaticUpdateManager(os.Args[1:])
automaticNotice := ""
var automaticCheckDone <-chan struct{}
if automaticUpdater != nil {
automaticNotice = automaticUpdater.CachedNotice()
automaticCheckDone = automaticUpdater.StartBackgroundCheck(context.Background())
}
analytics.Init(version)
start := time.Now()
err := rootCmd.Execute()
if automaticNotice != "" && err == nil {
fmt.Fprintln(os.Stderr, automaticNotice)
}
drainAutomaticUpdateCheck(automaticCheckDone)
analytics.Track(commandName(os.Args[1:]), err == nil, time.Since(start))
analytics.Close()
@@ -183,6 +200,93 @@ func envBool(key string) bool {
return v == "1" || v == "true"
}
func newAutomaticUpdateManager(args []string) *update.Manager {
if shouldSkipAutomaticUpdates(args) {
return nil
}
return update.NewManager(update.Options{
CurrentVersion: version,
JSONOutput: requestedBoolFlag(args, "--json", jsonOut),
Debug: requestedBoolFlag(args, "--debug", debug),
Automatic: true,
})
}
func shouldSkipAutomaticUpdates(args []string) bool {
if hasHelpFlag(args) || requestedBoolFlag(args, "--version", false) {
return true
}
switch primaryCommand(args) {
case "help", "completion", "update", "self-update", "upgrade":
return true
default:
return false
}
}
func hasHelpFlag(args []string) bool {
if requestedBoolFlag(args, "--help", false) {
return true
}
for _, arg := range args {
if arg == "-h" {
return true
}
}
return false
}
func primaryCommand(args []string) string {
for _, arg := range args {
if strings.HasPrefix(arg, "-") {
continue
}
return arg
}
return ""
}
func requestedBoolFlag(args []string, flagName string, current bool) bool {
if current {
return true
}
prefix := flagName + "="
for _, arg := range args {
if arg == flagName {
return true
}
if strings.HasPrefix(arg, prefix) {
value, err := strconv.ParseBool(strings.TrimPrefix(arg, prefix))
return err == nil && value
}
}
return false
}
func drainAutomaticUpdateCheck(done <-chan struct{}) {
drainAutomaticUpdateCheckWithTimeout(done, automaticUpdateDrainTimeout)
}
func drainAutomaticUpdateCheckWithTimeout(done <-chan struct{}, timeout time.Duration) {
if done == nil {
return
}
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case <-done:
case <-timer.C:
}
}
func defaultServerURL() string {
// 1. Explicit env var always wins
if env := normalizeServerURL(os.Getenv("BROWSEROS_URL")); env != "" {

View File

@@ -1,6 +1,9 @@
package cmd
import "testing"
import (
"testing"
"time"
)
func TestCommandName(t *testing.T) {
tests := []struct {
@@ -23,3 +26,103 @@ func TestCommandName(t *testing.T) {
})
}
}
func TestPrimaryCommand(t *testing.T) {
tests := []struct {
name string
args []string
want string
}{
{"empty", nil, ""},
{"root flag then command", []string{"--json", "update"}, "update"},
{"subcommand", []string{"bookmark", "update"}, "bookmark"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := primaryCommand(tt.args); got != tt.want {
t.Fatalf("primaryCommand(%v) = %q, want %q", tt.args, got, tt.want)
}
})
}
}
func TestRequestedBoolFlag(t *testing.T) {
if !requestedBoolFlag([]string{"--json"}, "--json", false) {
t.Fatal("requestedBoolFlag() = false, want true")
}
if !requestedBoolFlag([]string{"--debug=true"}, "--debug", false) {
t.Fatal("requestedBoolFlag() with assignment = false, want true")
}
if requestedBoolFlag([]string{"--debug=false"}, "--debug", false) {
t.Fatal("requestedBoolFlag() with false assignment = true, want false")
}
}
func TestShouldSkipAutomaticUpdates(t *testing.T) {
tests := []struct {
name string
args []string
want bool
}{
{"short help flag", []string{"-h"}, true},
{"help flag", []string{"--help"}, true},
{"version flag", []string{"--version"}, true},
{"update command", []string{"update"}, true},
{"bookmark update subcommand", []string{"bookmark", "update"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := shouldSkipAutomaticUpdates(tt.args); got != tt.want {
t.Fatalf("shouldSkipAutomaticUpdates(%v) = %t, want %t", tt.args, got, tt.want)
}
})
}
}
func TestDrainAutomaticUpdateCheckWithTimeoutWaitsForCompletion(t *testing.T) {
done := make(chan struct{})
returned := make(chan struct{})
go func() {
drainAutomaticUpdateCheckWithTimeout(done, time.Second)
close(returned)
}()
select {
case <-returned:
t.Fatal("drainAutomaticUpdateCheckWithTimeout() returned before check completed")
case <-time.After(10 * time.Millisecond):
}
close(done)
select {
case <-returned:
case <-time.After(100 * time.Millisecond):
t.Fatal("drainAutomaticUpdateCheckWithTimeout() did not return after check completed")
}
}
func TestDrainAutomaticUpdateCheckWithTimeoutStopsWaiting(t *testing.T) {
done := make(chan struct{})
returned := make(chan struct{})
go func() {
drainAutomaticUpdateCheckWithTimeout(done, 20*time.Millisecond)
close(returned)
}()
select {
case <-returned:
t.Fatal("drainAutomaticUpdateCheckWithTimeout() returned before timeout elapsed")
case <-time.After(5 * time.Millisecond):
}
select {
case <-returned:
case <-time.After(100 * time.Millisecond):
t.Fatal("drainAutomaticUpdateCheckWithTimeout() did not return after timeout")
}
}

View File

@@ -0,0 +1,179 @@
package cmd
import (
"bufio"
"context"
"fmt"
"io"
"os"
"strings"
"browseros-cli/output"
"browseros-cli/update"
"github.com/spf13/cobra"
)
type updateManager interface {
CheckNow(context.Context) (*update.CheckResult, error)
Apply(context.Context, *update.CheckResult) error
}
type updateOutcome struct {
result *update.CheckResult
applied bool
canceled bool
}
func init() {
cmd := &cobra.Command{
Use: "update",
Aliases: []string{"self-update", "upgrade"},
Annotations: map[string]string{"group": "Setup:"},
Short: "Check for and apply CLI updates",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
checkOnly, _ := cmd.Flags().GetBool("check")
yes, _ := cmd.Flags().GetBool("yes")
manager := update.NewManager(update.Options{
CurrentVersion: version,
JSONOutput: jsonOut,
Debug: debug,
Automatic: false,
})
outcome, err := runUpdateCommand(
cmd.Context(),
manager,
checkOnly,
yes,
stdinIsInteractive(os.Stdin),
os.Stdin,
os.Stderr,
)
if err != nil {
output.Error(err.Error(), 1)
}
printUpdateOutcome(outcome)
},
}
cmd.Flags().Bool("check", false, "Check for updates without applying them")
cmd.Flags().Bool("yes", false, "Apply update without prompting")
rootCmd.AddCommand(cmd)
}
func runUpdateCommand(
ctx context.Context,
manager updateManager,
checkOnly bool,
yes bool,
interactive bool,
stdin io.Reader,
stderr io.Writer,
) (*updateOutcome, error) {
result, err := manager.CheckNow(ctx)
if err != nil {
return nil, err
}
outcome := &updateOutcome{result: result}
if checkOnly || !result.UpdateAvailable {
return outcome, nil
}
if !yes {
if !interactive {
return nil, fmt.Errorf("update requires confirmation; rerun with --yes")
}
confirmed, err := confirmUpdate(stdin, stderr, result)
if err != nil {
return nil, err
}
if !confirmed {
outcome.canceled = true
return outcome, nil
}
}
if err := manager.Apply(ctx, result); err != nil {
return nil, err
}
outcome.applied = true
return outcome, nil
}
func printUpdateOutcome(outcome *updateOutcome) {
if jsonOut {
output.JSONRaw(updateOutcomePayload(outcome))
return
}
switch {
case outcome.applied:
fmt.Printf("Updated browseros-cli to v%s\n", outcome.result.LatestVersion)
case outcome.canceled:
fmt.Println("Update canceled.")
case outcome.result.UpdateAvailable:
fmt.Println(update.FormatNotice(outcome.result.CurrentVersion, outcome.result.LatestVersion))
case outcome.result != nil:
fmt.Printf("browseros-cli is up to date (v%s)\n", outcome.result.CurrentVersion)
}
}
func updateOutcomePayload(outcome *updateOutcome) map[string]any {
payload := map[string]any{
"applied": outcome.applied,
}
if outcome.canceled {
payload["canceled"] = true
}
if outcome.result == nil {
return payload
}
payload["currentVersion"] = outcome.result.CurrentVersion
payload["latestVersion"] = outcome.result.LatestVersion
payload["updateAvailable"] = outcome.result.UpdateAvailable
if outcome.result.Asset != nil {
payload["asset"] = map[string]any{
"filename": outcome.result.Asset.Filename,
"url": outcome.result.Asset.URL,
"archiveFormat": outcome.result.Asset.ArchiveFormat,
}
}
return payload
}
func confirmUpdate(
stdin io.Reader,
stderr io.Writer,
result *update.CheckResult,
) (bool, error) {
if _, err := fmt.Fprintf(
stderr,
"Install browseros-cli v%s over v%s? [y/N]: ",
result.LatestVersion,
result.CurrentVersion,
); err != nil {
return false, err
}
line, err := bufio.NewReader(stdin).ReadString('\n')
if err != nil && err != io.EOF {
return false, err
}
answer := strings.ToLower(strings.TrimSpace(line))
return answer == "y" || answer == "yes", nil
}
func stdinIsInteractive(file *os.File) bool {
info, err := file.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}

View File

@@ -0,0 +1,176 @@
package cmd
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"browseros-cli/update"
)
func TestRunUpdateCommandCheckOnly(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
manager := newTestUpdateManager(t)
outcome, err := runUpdateCommand(
context.Background(),
manager,
true,
false,
false,
bytes.NewBufferString(""),
&bytes.Buffer{},
)
if err != nil {
t.Fatalf("runUpdateCommand() error = %v", err)
}
if outcome.applied {
t.Fatal("runUpdateCommand() applied = true, want false")
}
if !outcome.result.UpdateAvailable {
t.Fatal("runUpdateCommand() UpdateAvailable = false, want true")
}
}
func TestRunUpdateCommandRequiresYesWithoutTTY(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
_, err := runUpdateCommand(
context.Background(),
newTestUpdateManager(t),
false,
false,
false,
bytes.NewBufferString(""),
&bytes.Buffer{},
)
if err == nil {
t.Fatal("runUpdateCommand() error = nil, want confirmation error")
}
}
func TestRunUpdateCommandCancel(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
stderr := &bytes.Buffer{}
outcome, err := runUpdateCommand(
context.Background(),
newTestUpdateManager(t),
false,
false,
true,
bytes.NewBufferString("n\n"),
stderr,
)
if err != nil {
t.Fatalf("runUpdateCommand() error = %v", err)
}
if !outcome.canceled {
t.Fatal("runUpdateCommand() canceled = false, want true")
}
if stderr.Len() == 0 {
t.Fatal("confirm prompt was not written to stderr")
}
}
func TestRunUpdateCommandYesAppliesWithoutPrompt(t *testing.T) {
manager := &fakeUpdateManager{
result: &update.CheckResult{
CurrentVersion: "1.0.0",
LatestVersion: "9.9.9",
UpdateAvailable: true,
Asset: &update.Asset{
Filename: "browseros-cli_9.9.9_test.tar.gz",
URL: "https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
ArchiveFormat: "tar.gz",
SHA256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
},
},
}
stderr := &bytes.Buffer{}
outcome, err := runUpdateCommand(
context.Background(),
manager,
false,
true,
false,
bytes.NewBufferString(""),
stderr,
)
if err != nil {
t.Fatalf("runUpdateCommand() error = %v", err)
}
if !outcome.applied {
t.Fatal("runUpdateCommand() applied = false, want true")
}
if manager.applyCalls != 1 {
t.Fatalf("Apply() calls = %d, want 1", manager.applyCalls)
}
if stderr.Len() != 0 {
t.Fatal("prompt was written despite --yes")
}
}
type fakeUpdateManager struct {
result *update.CheckResult
checkErr error
applyErr error
applyCalls int
}
func (m *fakeUpdateManager) CheckNow(context.Context) (*update.CheckResult, error) {
if m.checkErr != nil {
return nil, m.checkErr
}
if m.result == nil {
return nil, errors.New("missing check result")
}
return m.result, nil
}
func (m *fakeUpdateManager) Apply(context.Context, *update.CheckResult) error {
m.applyCalls++
return m.applyErr
}
func newTestUpdateManager(t *testing.T) *update.Manager {
t.Helper()
key, err := update.PlatformKey(runtime.GOOS, runtime.GOARCH)
if err != nil {
t.Fatalf("PlatformKey() error = %v", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"version":"9.9.9",
"published_at":"2026-03-27T19:00:00Z",
"tag":"browseros-cli-v9.9.9",
"assets":{
"` + key + `":{
"filename":"browseros-cli_9.9.9_test.tar.gz",
"url":"https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
"archive_format":"tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}`))
}))
t.Cleanup(server.Close)
return update.NewManager(update.Options{
CurrentVersion: "1.0.0",
ManifestURL: server.URL,
Automatic: false,
HTTPClient: server.Client(),
})
}

View File

@@ -4,13 +4,16 @@ go 1.25.7
require (
github.com/fatih/color v1.18.0
github.com/minio/selfupdate v0.6.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/posthog/posthog-go v1.11.2
github.com/spf13/cobra v1.10.2
golang.org/x/mod v0.34.0
gopkg.in/yaml.v3 v3.0.1
)
require (
aead.dev/minisign v0.2.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
@@ -22,6 +25,7 @@ require (
github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
)

View File

@@ -1,3 +1,5 @@
aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk=
aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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=
@@ -22,6 +24,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
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/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU=
github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM=
github.com/modelcontextprotocol/go-sdk v1.4.0 h1:u0kr8lbJc1oBcawK7Df+/ajNMpIDFE41OEPxdeTLOn8=
github.com/modelcontextprotocol/go-sdk v1.4.0/go.mod h1:Nxc2n+n/GdCebUaqCOhTetptS17SXXNu9IfNTaLDi1E=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -42,14 +46,33 @@ github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -0,0 +1,49 @@
package update
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/minio/selfupdate"
)
func CheckPermissions(targetPath string) error {
options := selfupdate.Options{TargetPath: targetPath}
return options.CheckPermissions()
}
func VerifyChecksum(data []byte, expectedHex string) error {
expected, err := decodeChecksum(expectedHex)
if err != nil {
return err
}
actual := sha256.Sum256(data)
if !bytes.Equal(actual[:], expected) {
return fmt.Errorf(
"checksum mismatch: expected %s, got %s",
hex.EncodeToString(expected),
hex.EncodeToString(actual[:]),
)
}
return nil
}
func ApplyBinary(binary []byte, targetPath string) error {
options := selfupdate.Options{TargetPath: targetPath}
err := selfupdate.Apply(bytes.NewReader(binary), options)
if rollbackErr := selfupdate.RollbackError(err); rollbackErr != nil {
return fmt.Errorf("update failed and rollback failed: %w", rollbackErr)
}
return err
}
func decodeChecksum(checksumHex string) ([]byte, error) {
value := strings.TrimSpace(checksumHex)
if value == "" {
return nil, fmt.Errorf("missing checksum")
}
return hex.DecodeString(value)
}

View File

@@ -0,0 +1,138 @@
package update
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"fmt"
"io"
"net/http"
)
const maxAssetSize = 64 << 20
const maxBinarySize = 256 << 20
func DownloadAsset(ctx context.Context, client *http.Client, asset Asset) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, asset.URL, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update download returned HTTP %d", resp.StatusCode)
}
return readAssetBytes(resp.Body)
}
func readAssetBytes(reader io.Reader) ([]byte, error) {
limited := io.LimitReader(reader, maxAssetSize+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(data) > maxAssetSize {
return nil, fmt.Errorf("update asset exceeds %d bytes", maxAssetSize)
}
return data, nil
}
func ExtractBinary(archive []byte, format string) ([]byte, error) {
switch format {
case "tar.gz":
return extractTarGzBinary(archive)
case "zip":
return extractZipBinary(archive)
default:
return nil, fmt.Errorf("unsupported archive format %q", format)
}
}
func extractTarGzBinary(archive []byte) ([]byte, error) {
gzipReader, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
return nil, err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
return readTarBinary(tarReader)
}
func readTarBinary(reader *tar.Reader) ([]byte, error) {
var binary []byte
for {
header, err := reader.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if header.Typeflag != tar.TypeReg {
continue
}
if binary != nil {
return nil, fmt.Errorf("archive contains multiple files; expected exactly one binary")
}
binary, err = io.ReadAll(io.LimitReader(reader, maxBinarySize+1))
if err != nil {
return nil, err
}
if len(binary) > maxBinarySize {
return nil, fmt.Errorf("extracted binary exceeds %d bytes", maxBinarySize)
}
}
if binary == nil {
return nil, fmt.Errorf("archive does not contain a file")
}
return binary, nil
}
func extractZipBinary(archive []byte) ([]byte, error) {
reader, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
if err != nil {
return nil, err
}
var binary []byte
for _, file := range reader.File {
if file.FileInfo().IsDir() {
continue
}
if binary != nil {
return nil, fmt.Errorf("archive contains multiple files; expected exactly one binary")
}
rc, err := file.Open()
if err != nil {
return nil, err
}
binary, err = io.ReadAll(io.LimitReader(rc, maxBinarySize+1))
rc.Close()
if err != nil {
return nil, err
}
if len(binary) > maxBinarySize {
return nil, fmt.Errorf("extracted binary exceeds %d bytes", maxBinarySize)
}
}
if binary == nil {
return nil, fmt.Errorf("archive does not contain a file")
}
return binary, nil
}

View File

@@ -0,0 +1,168 @@
package update
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
)
func TestExtractBinaryTarGz(t *testing.T) {
archive := createTarGz(t, map[string]string{"browseros-cli": "new-binary"})
binary, err := ExtractBinary(archive, "tar.gz")
if err != nil {
t.Fatalf("ExtractBinary() error = %v", err)
}
if string(binary) != "new-binary" {
t.Fatalf("ExtractBinary() = %q, want %q", string(binary), "new-binary")
}
}
func TestExtractBinaryZip(t *testing.T) {
archive := createZip(t, map[string]string{"browseros-cli.exe": "new-binary"})
binary, err := ExtractBinary(archive, "zip")
if err != nil {
t.Fatalf("ExtractBinary() error = %v", err)
}
if string(binary) != "new-binary" {
t.Fatalf("ExtractBinary() = %q, want %q", string(binary), "new-binary")
}
}
func TestExtractBinaryTarGzRejectsMultipleFiles(t *testing.T) {
archive := createTarGz(t, map[string]string{
"browseros-cli": "new-binary",
"browseros-cli.sig": "signature",
})
_, err := ExtractBinary(archive, "tar.gz")
if err == nil {
t.Fatal("ExtractBinary() error = nil, want multiple files error")
}
if err.Error() != "archive contains multiple files; expected exactly one binary" {
t.Fatalf("ExtractBinary() error = %q", err)
}
}
func TestVerifyChecksumValid(t *testing.T) {
data := []byte("some-data")
sum := sha256.Sum256(data)
if err := VerifyChecksum(data, hex.EncodeToString(sum[:])); err != nil {
t.Fatalf("VerifyChecksum() error = %v", err)
}
}
func TestVerifyChecksumMismatch(t *testing.T) {
data := []byte("some-data")
badChecksum := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
if err := VerifyChecksum(data, badChecksum); err == nil {
t.Fatal("VerifyChecksum() error = nil, want mismatch error")
}
}
func TestApplyBinary(t *testing.T) {
targetPath := filepath.Join(t.TempDir(), "browseros-cli")
if err := os.WriteFile(targetPath, []byte("old-binary"), 0755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
newBinary := []byte("new-binary")
if err := ApplyBinary(newBinary, targetPath); err != nil {
t.Fatalf("ApplyBinary() error = %v", err)
}
data, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "new-binary" {
t.Fatalf("updated binary = %q, want %q", string(data), "new-binary")
}
}
func TestVerifyThenApplyIntegration(t *testing.T) {
archive := createTarGz(t, map[string]string{"browseros-cli": "updated-binary"})
archiveSum := sha256.Sum256(archive)
if err := VerifyChecksum(archive, hex.EncodeToString(archiveSum[:])); err != nil {
t.Fatalf("VerifyChecksum(archive) error = %v", err)
}
binary, err := ExtractBinary(archive, "tar.gz")
if err != nil {
t.Fatalf("ExtractBinary() error = %v", err)
}
targetPath := filepath.Join(t.TempDir(), "browseros-cli")
if err := os.WriteFile(targetPath, []byte("old"), 0755); err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
if err := ApplyBinary(binary, targetPath); err != nil {
t.Fatalf("ApplyBinary() error = %v", err)
}
data, err := os.ReadFile(targetPath)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != "updated-binary" {
t.Fatalf("binary = %q, want %q", string(data), "updated-binary")
}
}
func createTarGz(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
gzipWriter := gzip.NewWriter(&buffer)
tarWriter := tar.NewWriter(gzipWriter)
for name, body := range files {
data := []byte(body)
if err := tarWriter.WriteHeader(&tar.Header{
Name: name,
Mode: 0755,
Size: int64(len(data)),
}); err != nil {
t.Fatalf("WriteHeader() error = %v", err)
}
if _, err := tarWriter.Write(data); err != nil {
t.Fatalf("Write() error = %v", err)
}
}
if err := tarWriter.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
if err := gzipWriter.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
return buffer.Bytes()
}
func createZip(t *testing.T, files map[string]string) []byte {
t.Helper()
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
for name, body := range files {
fileWriter, err := zipWriter.Create(name)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if _, err := fileWriter.Write([]byte(body)); err != nil {
t.Fatalf("Write() error = %v", err)
}
}
if err := zipWriter.Close(); err != nil {
t.Fatalf("Close() error = %v", err)
}
return buffer.Bytes()
}

View File

@@ -0,0 +1,249 @@
package update
import (
"context"
"fmt"
"net/http"
"os"
"runtime"
"time"
)
const (
DefaultManifestURL = "https://cdn.browseros.com/cli/latest/manifest.json"
DefaultCheckTTL = 24 * time.Hour
DefaultHTTPTimeout = 2 * time.Second
DefaultDownloadTimeout = 5 * time.Minute
SkipCheckEnv = "BROWSEROS_SKIP_UPDATE_CHECK"
)
type Options struct {
CurrentVersion string
ManifestURL string
CheckTTL time.Duration
HTTPTimeout time.Duration
DownloadTimeout time.Duration
JSONOutput bool
Debug bool
Automatic bool
HTTPClient *http.Client
Now func() time.Time
}
type Manager struct {
options Options
state *State
}
type CheckResult struct {
CurrentVersion string `json:"current_version"`
LatestVersion string `json:"latest_version"`
LatestPublishedAt string `json:"latest_published_at,omitempty"`
UpdateAvailable bool `json:"update_available"`
CheckedAt time.Time `json:"checked_at"`
Asset *Asset `json:"asset,omitempty"`
}
func NewManager(options Options) *Manager {
if options.ManifestURL == "" {
options.ManifestURL = DefaultManifestURL
}
if options.CheckTTL == 0 {
options.CheckTTL = DefaultCheckTTL
}
if options.HTTPTimeout == 0 {
options.HTTPTimeout = DefaultHTTPTimeout
}
if options.DownloadTimeout == 0 {
options.DownloadTimeout = DefaultDownloadTimeout
}
if options.Now == nil {
options.Now = time.Now
}
if options.HTTPClient == nil {
options.HTTPClient = &http.Client{}
}
state, err := LoadState()
if err != nil {
state = &State{}
}
return &Manager{
options: options,
state: state,
}
}
func (m *Manager) CachedNotice() string {
if !m.AutomaticEnabled() || m.state == nil || m.state.LatestVersion == "" {
return ""
}
comparison, err := CompareVersions(m.options.CurrentVersion, m.state.LatestVersion)
if err != nil || comparison >= 0 {
return ""
}
return FormatNotice(m.options.CurrentVersion, m.state.LatestVersion)
}
func (m *Manager) AutomaticEnabled() bool {
if !m.options.Automatic || m.options.JSONOutput {
return false
}
if os.Getenv(SkipCheckEnv) != "" {
return false
}
return IsReleaseVersion(m.options.CurrentVersion)
}
func (m *Manager) ShouldCheck() bool {
if !m.AutomaticEnabled() {
return false
}
return m.state.IsStale(m.options.Now(), m.options.CheckTTL)
}
func (m *Manager) StartBackgroundCheck(ctx context.Context) <-chan struct{} {
done := make(chan struct{})
if !m.ShouldCheck() {
close(done)
return done
}
go func() {
defer close(done)
_, _ = m.CheckNow(ctx)
}()
return done
}
func (m *Manager) CheckNow(ctx context.Context) (*CheckResult, error) {
if !IsReleaseVersion(m.options.CurrentVersion) {
return nil, fmt.Errorf("self-update is unavailable for non-release build %q", m.options.CurrentVersion)
}
checkCtx, cancel := context.WithTimeout(ctx, m.options.HTTPTimeout)
defer cancel()
manifest, err := FetchManifest(checkCtx, cloneHTTPClient(m.options.HTTPClient), m.options.ManifestURL)
if err != nil {
m.recordError(err)
return nil, err
}
asset, err := SelectAsset(manifest, runtime.GOOS, runtime.GOARCH)
if err != nil {
m.recordError(err)
return nil, err
}
comparison, err := CompareVersions(m.options.CurrentVersion, manifest.Version)
if err != nil {
m.recordError(err)
return nil, err
}
result := &CheckResult{
CurrentVersion: m.options.CurrentVersion,
LatestVersion: manifest.Version,
LatestPublishedAt: manifest.PublishedAt,
UpdateAvailable: comparison < 0,
CheckedAt: m.options.Now(),
}
if result.UpdateAvailable {
assetCopy := asset
result.Asset = &assetCopy
}
m.state = &State{
LastCheckedAt: result.CheckedAt,
LatestVersion: manifest.Version,
LatestPublishedAt: manifest.PublishedAt,
AssetURL: asset.URL,
}
_ = SaveState(m.state)
return result, nil
}
func (m *Manager) Apply(ctx context.Context, result *CheckResult) error {
if result == nil || !result.UpdateAvailable || result.Asset == nil {
return fmt.Errorf("browseros-cli is already up to date")
}
downloadCtx, cancel := context.WithTimeout(ctx, m.options.DownloadTimeout)
defer cancel()
archive, err := DownloadAsset(downloadCtx, cloneHTTPClient(m.options.HTTPClient), *result.Asset)
if err != nil {
return err
}
if err := VerifyChecksum(archive, result.Asset.SHA256); err != nil {
return err
}
binary, err := ExtractBinary(archive, result.Asset.ArchiveFormat)
if err != nil {
return err
}
targetPath, err := os.Executable()
if err != nil {
return err
}
if err := CheckPermissions(targetPath); err != nil {
return fmt.Errorf(
"cannot replace %s: %w\n\nReinstall with the installer script or move the binary to a writable location.",
targetPath,
err,
)
}
if err := ApplyBinary(binary, targetPath); err != nil {
return err
}
m.saveAppliedState(result)
return nil
}
func FormatNotice(currentVersion, latestVersion string) string {
return fmt.Sprintf(
"Update available: browseros-cli v%s (current v%s)\nRun `browseros-cli update` to upgrade.",
latestVersion,
currentVersion,
)
}
func (m *Manager) recordError(err error) {
state := &State{}
if m.state != nil {
*state = *m.state
}
state.CheckError = err.Error()
m.state = state
_ = SaveState(state)
}
func (m *Manager) saveAppliedState(result *CheckResult) {
state := &State{
LastCheckedAt: m.options.Now(),
LatestVersion: result.LatestVersion,
LatestPublishedAt: result.LatestPublishedAt,
AssetURL: result.Asset.URL,
}
m.state = state
_ = SaveState(state)
}
func cloneHTTPClient(client *http.Client) *http.Client {
if client == nil {
return &http.Client{}
}
cloned := *client
cloned.Timeout = 0
return &cloned
}

View File

@@ -0,0 +1,162 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"runtime"
"testing"
"time"
)
func TestManagerCachedNotice(t *testing.T) {
manager := NewManager(Options{
CurrentVersion: "1.0.0",
Automatic: true,
})
manager.state = &State{LatestVersion: "1.2.0"}
notice := manager.CachedNotice()
if notice == "" {
t.Fatal("CachedNotice() returned empty notice")
}
}
func TestManagerShouldCheck(t *testing.T) {
manager := NewManager(Options{
CurrentVersion: "1.0.0",
Automatic: true,
CheckTTL: time.Minute,
Now: func() time.Time {
return time.Unix(1000, 0).UTC()
},
})
manager.state = &State{LastCheckedAt: time.Unix(0, 0).UTC()}
if !manager.ShouldCheck() {
t.Fatal("ShouldCheck() = false, want true")
}
}
func TestManagerCheckNow(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"version":"9.9.9",
"published_at":"2026-03-27T19:00:00Z",
"tag":"browseros-cli-v9.9.9",
"assets":{
"` + runtimePlatformKey(t) + `":{
"filename":"browseros-cli_9.9.9_test.tar.gz",
"url":"https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
"archive_format":"tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}`))
}))
defer server.Close()
manager := NewManager(Options{
CurrentVersion: "1.0.0",
ManifestURL: server.URL,
Automatic: false,
HTTPClient: server.Client(),
Now: func() time.Time {
return time.Unix(100, 0).UTC()
},
})
result, err := manager.CheckNow(context.Background())
if err != nil {
t.Fatalf("CheckNow() error = %v", err)
}
if !result.UpdateAvailable {
t.Fatal("CheckNow() UpdateAvailable = false, want true")
}
if result.LatestPublishedAt != "2026-03-27T19:00:00Z" {
t.Fatalf(
"CheckNow() LatestPublishedAt = %q, want %q",
result.LatestPublishedAt,
"2026-03-27T19:00:00Z",
)
}
if manager.state.LatestPublishedAt != "2026-03-27T19:00:00Z" {
t.Fatalf(
"state LatestPublishedAt = %q, want %q",
manager.state.LatestPublishedAt,
"2026-03-27T19:00:00Z",
)
}
}
func TestCloneHTTPClientClearsTimeout(t *testing.T) {
base := &http.Client{Timeout: time.Second}
cloned := cloneHTTPClient(base)
if cloned == base {
t.Fatal("cloneHTTPClient() returned the original client")
}
if cloned.Timeout != 0 {
t.Fatalf("cloneHTTPClient() Timeout = %s, want 0", cloned.Timeout)
}
if base.Timeout != time.Second {
t.Fatalf("base Timeout = %s, want %s", base.Timeout, time.Second)
}
}
func TestManagerSaveAppliedState(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
now := time.Unix(200, 0).UTC()
manager := NewManager(Options{
CurrentVersion: "1.0.0",
Now: func() time.Time {
return now
},
})
manager.state = &State{
LastCheckedAt: time.Unix(100, 0).UTC(),
CheckError: "manifest fetch failed",
}
manager.saveAppliedState(&CheckResult{
LatestVersion: "9.9.9",
LatestPublishedAt: "2026-03-27T19:00:00Z",
Asset: &Asset{
URL: "https://cdn.example.com/cli/v9.9.9/browseros-cli_9.9.9_test.tar.gz",
},
})
if manager.state.LastCheckedAt != now {
t.Fatalf("LastCheckedAt = %v, want %v", manager.state.LastCheckedAt, now)
}
if manager.state.CheckError != "" {
t.Fatalf("CheckError = %q, want empty", manager.state.CheckError)
}
if manager.state.LatestPublishedAt != "2026-03-27T19:00:00Z" {
t.Fatalf("LatestPublishedAt = %q", manager.state.LatestPublishedAt)
}
}
func runtimePlatformKey(t *testing.T) string {
t.Helper()
key, err := PlatformKey(runtimeGOOS(), runtimeGOARCH())
if err != nil {
t.Fatalf("PlatformKey() error = %v", err)
}
return key
}
func runtimeGOOS() string {
return runtime.GOOS
}
func runtimeGOARCH() string {
return runtime.GOARCH
}

View File

@@ -0,0 +1,144 @@
package update
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"golang.org/x/mod/semver"
)
const maxManifestSize = 1 << 20
type Manifest struct {
Version string `json:"version"`
PublishedAt string `json:"published_at"`
Tag string `json:"tag"`
Assets map[string]Asset `json:"assets"`
}
type Asset struct {
Filename string `json:"filename"`
URL string `json:"url"`
ArchiveFormat string `json:"archive_format"`
SHA256 string `json:"sha256"`
}
func FetchManifest(
ctx context.Context,
client *http.Client,
url string,
) (*Manifest, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("update manifest returned HTTP %d", resp.StatusCode)
}
var manifest Manifest
if err := json.NewDecoder(io.LimitReader(resp.Body, maxManifestSize)).Decode(&manifest); err != nil {
return nil, err
}
if err := manifest.Validate(); err != nil {
return nil, err
}
return &manifest, nil
}
func (m *Manifest) Validate() error {
if m == nil {
return fmt.Errorf("update manifest is nil")
}
if !IsReleaseVersion(m.Version) {
return fmt.Errorf("invalid manifest version %q", m.Version)
}
if len(m.Assets) == 0 {
return fmt.Errorf("update manifest has no assets")
}
for key, asset := range m.Assets {
if asset.URL == "" {
return fmt.Errorf("asset %q is missing url", key)
}
if asset.SHA256 == "" {
return fmt.Errorf("asset %q is missing sha256", key)
}
if asset.ArchiveFormat != "tar.gz" && asset.ArchiveFormat != "zip" {
return fmt.Errorf("asset %q has unsupported archive format %q", key, asset.ArchiveFormat)
}
}
return nil
}
func NormalizeVersion(version string) string {
value := strings.TrimSpace(version)
if value == "" {
return ""
}
if !strings.HasPrefix(value, "v") {
value = "v" + value
}
return semver.Canonical(value)
}
func IsReleaseVersion(version string) bool {
return NormalizeVersion(version) != ""
}
func CompareVersions(current, latest string) (int, error) {
normalizedCurrent := NormalizeVersion(current)
if normalizedCurrent == "" {
return 0, fmt.Errorf("invalid current version %q", current)
}
normalizedLatest := NormalizeVersion(latest)
if normalizedLatest == "" {
return 0, fmt.Errorf("invalid latest version %q", latest)
}
return semver.Compare(normalizedCurrent, normalizedLatest), nil
}
func PlatformKey(goos, goarch string) (string, error) {
switch goos {
case "darwin", "linux", "windows":
default:
return "", fmt.Errorf("unsupported os %q", goos)
}
switch goarch {
case "amd64", "arm64":
default:
return "", fmt.Errorf("unsupported arch %q", goarch)
}
return goos + "/" + goarch, nil
}
func SelectAsset(manifest *Manifest, goos, goarch string) (Asset, error) {
key, err := PlatformKey(goos, goarch)
if err != nil {
return Asset{}, err
}
asset, ok := manifest.Assets[key]
if !ok {
return Asset{}, fmt.Errorf("no update asset for %s", key)
}
return asset, nil
}

View File

@@ -0,0 +1,102 @@
package update
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestNormalizeVersion(t *testing.T) {
if got := NormalizeVersion("1.2.3"); got != "v1.2.3" {
t.Fatalf("NormalizeVersion() = %q, want %q", got, "v1.2.3")
}
if got := NormalizeVersion("dev"); got != "" {
t.Fatalf("NormalizeVersion(dev) = %q, want empty", got)
}
}
func TestCompareVersions(t *testing.T) {
got, err := CompareVersions("1.2.3", "1.3.0")
if err != nil {
t.Fatalf("CompareVersions() error = %v", err)
}
if got >= 0 {
t.Fatalf("CompareVersions() = %d, want < 0", got)
}
}
func TestSelectAsset(t *testing.T) {
manifest := &Manifest{
Version: "1.2.3",
Assets: map[string]Asset{
"darwin/arm64": {
URL: "https://cdn.example.com/cli/v1.2.3/browseros-cli.tar.gz",
ArchiveFormat: "tar.gz",
SHA256: "abc",
},
},
}
asset, err := SelectAsset(manifest, "darwin", "arm64")
if err != nil {
t.Fatalf("SelectAsset() error = %v", err)
}
if asset.URL == "" {
t.Fatal("SelectAsset() returned empty URL")
}
}
func TestFetchManifest(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"version":"1.2.3",
"published_at":"2026-03-27T19:00:00Z",
"tag":"browseros-cli-v1.2.3",
"assets":{
"darwin/arm64":{
"filename":"browseros-cli_1.2.3_darwin_arm64.tar.gz",
"url":"https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_darwin_arm64.tar.gz",
"archive_format":"tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}`))
}))
defer server.Close()
manifest, err := FetchManifest(context.Background(), server.Client(), server.URL)
if err != nil {
t.Fatalf("FetchManifest() error = %v", err)
}
if manifest.Version != "1.2.3" {
t.Fatalf("FetchManifest() version = %q, want %q", manifest.Version, "1.2.3")
}
}
func TestFetchManifestRejectsOversizedResponse(t *testing.T) {
hugeName := strings.Repeat("a", maxManifestSize)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"version":"1.2.3",
"published_at":"2026-03-27T19:00:00Z",
"tag":"browseros-cli-v1.2.3",
"assets":{
"darwin/arm64":{
"filename":"` + hugeName + `",
"url":"https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_darwin_arm64.tar.gz",
"archive_format":"tar.gz",
"sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}`))
}))
defer server.Close()
if _, err := FetchManifest(context.Background(), server.Client(), server.URL); err == nil {
t.Fatal("FetchManifest() error = nil, want oversized response error")
}
}

View File

@@ -0,0 +1,80 @@
package update
import (
"encoding/json"
"os"
"path/filepath"
"time"
"browseros-cli/config"
)
type State struct {
LastCheckedAt time.Time `json:"last_checked_at"`
LatestVersion string `json:"latest_version,omitempty"`
LatestPublishedAt string `json:"latest_published_at,omitempty"`
AssetURL string `json:"asset_url,omitempty"`
CheckError string `json:"check_error,omitempty"`
}
func StatePath() string {
return filepath.Join(config.Dir(), "update-state.json")
}
func LoadState() (*State, error) {
data, err := os.ReadFile(StatePath())
if err != nil {
if os.IsNotExist(err) {
return &State{}, nil
}
return nil, err
}
var state State
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func SaveState(state *State) error {
if state == nil {
state = &State{}
}
dir := config.Dir()
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
tmpFile, err := os.CreateTemp(dir, "update-state-*.json")
if err != nil {
return err
}
encoder := json.NewEncoder(tmpFile)
encoder.SetIndent("", " ")
if err := encoder.Encode(state); err != nil {
tmpFile.Close()
os.Remove(tmpFile.Name())
return err
}
if err := tmpFile.Close(); err != nil {
os.Remove(tmpFile.Name())
return err
}
if err := os.Rename(tmpFile.Name(), StatePath()); err != nil {
os.Remove(tmpFile.Name())
return err
}
return nil
}
func (s *State) IsStale(now time.Time, ttl time.Duration) bool {
if s == nil || s.LastCheckedAt.IsZero() {
return true
}
return now.Sub(s.LastCheckedAt) >= ttl
}

View File

@@ -0,0 +1,54 @@
package update
import (
"path/filepath"
"testing"
"time"
)
func TestLoadStateMissing(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
state, err := LoadState()
if err != nil {
t.Fatalf("LoadState() error = %v", err)
}
if state == nil {
t.Fatal("LoadState() returned nil state")
}
}
func TestSaveStateRoundTrip(t *testing.T) {
configRoot := t.TempDir()
t.Setenv("XDG_CONFIG_HOME", configRoot)
want := &State{
LastCheckedAt: time.Unix(100, 0).UTC(),
LatestVersion: "1.2.3",
LatestPublishedAt: "2026-03-27T19:00:00Z",
AssetURL: "https://cdn.example.com/cli/v1.2.3/browseros-cli.tar.gz",
}
if err := SaveState(want); err != nil {
t.Fatalf("SaveState() error = %v", err)
}
got, err := LoadState()
if err != nil {
t.Fatalf("LoadState() error = %v", err)
}
if got.LatestVersion != want.LatestVersion {
t.Fatalf("LatestVersion = %q, want %q", got.LatestVersion, want.LatestVersion)
}
if StatePath() != filepath.Join(configRoot, "browseros-cli", "update-state.json") {
t.Fatalf("StatePath() = %q", StatePath())
}
}
func TestStateIsStale(t *testing.T) {
now := time.Unix(200, 0).UTC()
state := &State{LastCheckedAt: time.Unix(0, 0).UTC()}
if !state.IsStale(now, time.Minute) {
t.Fatal("IsStale() = false, want true")
}
}

View File

@@ -0,0 +1,100 @@
import { describe, expect, test } from 'bun:test'
import {
buildCliReleaseManifest,
parseCliArchiveFilename,
parseCliChecksums,
} from './upload'
describe('parseCliChecksums', () => {
test('parses checksum lines into a filename map', () => {
const checksums = parseCliChecksums(
[
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa browseros-cli_1.2.3_darwin_arm64.tar.gz',
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB *browseros-cli_1.2.3_windows_amd64.zip',
].join('\n'),
)
expect(checksums.get('browseros-cli_1.2.3_darwin_arm64.tar.gz')).toBe(
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
)
expect(checksums.get('browseros-cli_1.2.3_windows_amd64.zip')).toBe(
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
)
})
test('rejects malformed checksum lines', () => {
expect(() => parseCliChecksums('not-a-checksum')).toThrow(
'Invalid checksum line',
)
})
})
describe('parseCliArchiveFilename', () => {
test('parses tar.gz archives', () => {
expect(
parseCliArchiveFilename('browseros-cli_1.2.3_darwin_arm64.tar.gz'),
).toEqual({
filename: 'browseros-cli_1.2.3_darwin_arm64.tar.gz',
version: '1.2.3',
os: 'darwin',
arch: 'arm64',
archive_format: 'tar.gz',
})
})
test('returns null for unexpected names', () => {
expect(parseCliArchiveFilename('checksums.txt')).toBeNull()
})
})
describe('buildCliReleaseManifest', () => {
test('builds versioned asset URLs from archive names and checksums', () => {
const manifest = buildCliReleaseManifest({
version: '1.2.3',
filenames: [
'browseros-cli_1.2.3_windows_arm64.zip',
'browseros-cli_1.2.3_darwin_arm64.tar.gz',
],
checksumsContent: [
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa browseros-cli_1.2.3_darwin_arm64.tar.gz',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb browseros-cli_1.2.3_windows_arm64.zip',
].join('\n'),
published_at: '2026-03-27T19:00:00Z',
cdnBaseURL: 'https://cdn.example.com',
uploadPrefix: 'cli',
})
expect(manifest).toEqual({
version: '1.2.3',
published_at: '2026-03-27T19:00:00Z',
tag: 'browseros-cli-v1.2.3',
assets: {
'darwin/arm64': {
filename: 'browseros-cli_1.2.3_darwin_arm64.tar.gz',
url: 'https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_darwin_arm64.tar.gz',
archive_format: 'tar.gz',
sha256:
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
'windows/arm64': {
filename: 'browseros-cli_1.2.3_windows_arm64.zip',
url: 'https://cdn.example.com/cli/v1.2.3/browseros-cli_1.2.3_windows_arm64.zip',
archive_format: 'zip',
sha256:
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
},
})
})
test('rejects archives without matching checksums', () => {
expect(() =>
buildCliReleaseManifest({
version: '1.2.3',
filenames: ['browseros-cli_1.2.3_linux_amd64.tar.gz'],
checksumsContent: '',
}),
).toThrow('Missing checksum')
})
})

View File

@@ -1,4 +1,4 @@
import { existsSync, readdirSync } from 'node:fs'
import { existsSync, readdirSync, readFileSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
@@ -6,9 +6,12 @@ import { fileURLToPath } from 'node:url'
import { log } from '../log'
import { createR2Client, joinObjectKey, uploadFileToObject } from '../server/r2'
import { loadCliUploadConfig } from './config'
import { type CliUploadConfig, loadCliUploadConfig } from './config'
const CDN_BASE_URL = 'https://cdn.browseros.com'
const JSON_CONTENT_TYPE = 'application/json; charset=utf-8'
const CLI_ARCHIVE_PATTERN =
/^browseros-cli_(?<version>[^_]+)_(?<os>darwin|linux|windows)_(?<arch>amd64|arm64)\.(?<ext>tar\.gz|zip)$/
const INSTALLERS = [
{
@@ -28,6 +31,28 @@ export interface CliReleaseOptions {
binariesDir: string
}
export interface CliReleaseAsset {
filename: string
url: string
archive_format: 'tar.gz' | 'zip'
sha256: string
}
export interface CliReleaseManifest {
version: string
published_at: string
tag: string
assets: Record<string, CliReleaseAsset>
}
interface CliArchiveMetadata {
filename: string
version: string
os: string
arch: string
archive_format: 'tar.gz' | 'zip'
}
function resolveRootDir(): string {
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../..')
process.chdir(rootDir)
@@ -70,6 +95,130 @@ export async function uploadCliInstallers(rootDir: string): Promise<void> {
}
}
export function parseCliChecksums(contents: string): Map<string, string> {
const entries = new Map<string, string>()
for (const rawLine of contents.split('\n')) {
const line = rawLine.trim()
if (!line) {
continue
}
const match = line.match(/^([a-f0-9]{64})\s+\*?(.+)$/i)
if (!match) {
throw new Error(`Invalid checksum line: ${rawLine}`)
}
entries.set(match[2], match[1].toLowerCase())
}
return entries
}
export function parseCliArchiveFilename(
filename: string,
): CliArchiveMetadata | null {
const match = filename.match(CLI_ARCHIVE_PATTERN)
if (!match?.groups) {
return null
}
const archive_format = match.groups.ext as 'tar.gz' | 'zip'
return {
filename,
version: match.groups.version,
os: match.groups.os,
arch: match.groups.arch,
archive_format,
}
}
export function buildCliReleaseManifest(options: {
version: string
filenames: string[]
checksumsContent: string
published_at?: string
cdnBaseURL?: string
uploadPrefix?: string
}): CliReleaseManifest {
const checksumByFilename = parseCliChecksums(options.checksumsContent)
const assets: Record<string, CliReleaseAsset> = {}
const filenames = [...options.filenames].sort()
const cdnBaseURL = options.cdnBaseURL ?? CDN_BASE_URL
const uploadPrefix = options.uploadPrefix ?? 'cli'
for (const filename of filenames) {
const archive = parseCliArchiveFilename(filename)
if (archive === null) {
throw new Error(`Unexpected CLI archive filename: ${filename}`)
}
if (archive.version !== options.version) {
throw new Error(
`Archive ${filename} does not match release version ${options.version}`,
)
}
const checksum = checksumByFilename.get(filename)
if (!checksum) {
throw new Error(`Missing checksum for ${filename}`)
}
const assetKey = `${archive.os}/${archive.arch}`
assets[assetKey] = {
filename,
url: `${cdnBaseURL}/${joinObjectKey(uploadPrefix, `v${options.version}`, filename)}`,
archive_format: archive.archive_format,
sha256: checksum,
}
}
return {
version: options.version,
published_at: options.published_at ?? new Date().toISOString(),
tag: `browseros-cli-v${options.version}`,
assets,
}
}
async function uploadCliManifest(
client: ReturnType<typeof createR2Client>,
version: string,
releaseArchives: string[],
uploadPrefix: string,
absoluteBinariesDir: string,
r2: CliUploadConfig['r2'],
): Promise<void> {
const checksumsPath = join(absoluteBinariesDir, 'checksums.txt')
if (!existsSync(checksumsPath)) {
throw new Error('checksums.txt is required to build CLI manifest')
}
const manifest = buildCliReleaseManifest({
version,
filenames: releaseArchives,
checksumsContent: readFileSync(checksumsPath, 'utf-8'),
uploadPrefix,
})
const manifestPath = join(tmpdir(), `browseros-cli-manifest-${version}.json`)
await writeFile(
manifestPath,
`${JSON.stringify(manifest, null, 2)}\n`,
'utf-8',
)
const versionedKey = joinObjectKey(
uploadPrefix,
`v${version}`,
'manifest.json',
)
const latestKey = joinObjectKey(uploadPrefix, 'latest', 'manifest.json')
log.step('Uploading manifest.json')
await uploadFileToObject(client, r2, versionedKey, manifestPath, {
contentType: JSON_CONTENT_TYPE,
})
await uploadFileToObject(client, r2, latestKey, manifestPath, {
contentType: JSON_CONTENT_TYPE,
})
log.success(`Uploaded ${latestKey}`)
log.info(`${CDN_BASE_URL}/${latestKey}`)
}
async function uploadCliRelease(
rootDir: string,
options: CliReleaseOptions,
@@ -87,6 +236,7 @@ async function uploadCliRelease(
if (archives.length === 0) {
throw new Error(`No archives found in ${binariesDir}`)
}
const releaseArchives = archives.filter((f) => f !== 'checksums.txt')
const { r2 } = loadCliUploadConfig(rootDir)
const client = createR2Client(r2)
@@ -110,6 +260,15 @@ async function uploadCliRelease(
log.info(`${CDN_BASE_URL}/${versionedKey}`)
}
await uploadCliManifest(
client,
version,
releaseArchives,
r2.uploadPrefix,
absoluteBinariesDir,
r2,
)
const versionTxtPath = join(tmpdir(), 'browseros-cli-version.txt')
await writeFile(versionTxtPath, version, 'utf-8')
const versionKey = joinObjectKey(r2.uploadPrefix, 'latest', 'version.txt')