diff --git a/.github/workflows/release-cli.yml b/.github/workflows/release-cli.yml index 15a727f30..d77ca6eef 100644 --- a/.github/workflows/release-cli.yml +++ b/.github/workflows/release-cli.yml @@ -38,40 +38,13 @@ jobs: bun-version: "1.3.6" - name: Run tests - run: go test ./... -v + run: make test - name: Run vet - run: go vet ./... + run: make vet - name: Build all platforms - run: | - VERSION="${{ inputs.version }}" - LDFLAGS="-s -w -X main.version=${VERSION}" - DIST="dist" - mkdir -p "$DIST" - - for pair in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do - OS="${pair%/*}" - ARCH="${pair#*/}" - BIN="browseros-cli" - EXT="" - if [ "$OS" = "windows" ]; then EXT=".exe"; fi - - echo "Building ${OS}/${ARCH}..." - GOOS=$OS GOARCH=$ARCH CGO_ENABLED=0 go build -trimpath -ldflags "$LDFLAGS" -o "${DIST}/${BIN}${EXT}" . - - ARCHIVE="browseros-cli_${VERSION}_${OS}_${ARCH}" - if [ "$OS" = "windows" ]; then - (cd "$DIST" && zip "${ARCHIVE}.zip" "${BIN}${EXT}") - else - (cd "$DIST" && tar czf "${ARCHIVE}.tar.gz" "${BIN}") - fi - rm "${DIST}/${BIN}${EXT}" - done - - (cd "$DIST" && sha256sum *.tar.gz *.zip > checksums.txt) - echo "=== Built artifacts ===" - ls -lh "$DIST" + run: make release VERSION=${{ inputs.version }} POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }} - name: Install dependencies run: bun install diff --git a/packages/browseros-agent/apps/cli/.goreleaser.yml b/packages/browseros-agent/apps/cli/.goreleaser.yml deleted file mode 100644 index 66d27e7e1..000000000 --- a/packages/browseros-agent/apps/cli/.goreleaser.yml +++ /dev/null @@ -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 }}" diff --git a/packages/browseros-agent/apps/cli/Makefile b/packages/browseros-agent/apps/cli/Makefile index 2a287657c..68214b7e4 100644 --- a/packages/browseros-agent/apps/cli/Makefile +++ b/packages/browseros-agent/apps/cli/Makefile @@ -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) diff --git a/packages/browseros-agent/apps/cli/README.md b/packages/browseros-agent/apps/cli/README.md index 34c369b4c..357cc53c7 100644 --- a/packages/browseros-agent/apps/cli/README.md +++ b/packages/browseros-agent/apps/cli/README.md @@ -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 diff --git a/packages/browseros-agent/apps/cli/cmd/root.go b/packages/browseros-agent/apps/cli/cmd/root.go index a736e7576..5ce906e0b 100644 --- a/packages/browseros-agent/apps/cli/cmd/root.go +++ b/packages/browseros-agent/apps/cli/cmd/root.go @@ -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 != "" { diff --git a/packages/browseros-agent/apps/cli/cmd/root_test.go b/packages/browseros-agent/apps/cli/cmd/root_test.go index 87b665169..6af33a8da 100644 --- a/packages/browseros-agent/apps/cli/cmd/root_test.go +++ b/packages/browseros-agent/apps/cli/cmd/root_test.go @@ -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") + } +} diff --git a/packages/browseros-agent/apps/cli/cmd/update.go b/packages/browseros-agent/apps/cli/cmd/update.go new file mode 100644 index 000000000..aba7ca7fc --- /dev/null +++ b/packages/browseros-agent/apps/cli/cmd/update.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/cmd/update_test.go b/packages/browseros-agent/apps/cli/cmd/update_test.go new file mode 100644 index 000000000..799899de6 --- /dev/null +++ b/packages/browseros-agent/apps/cli/cmd/update_test.go @@ -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(), + }) +} diff --git a/packages/browseros-agent/apps/cli/go.mod b/packages/browseros-agent/apps/cli/go.mod index 1144d5164..c6c6c2f22 100644 --- a/packages/browseros-agent/apps/cli/go.mod +++ b/packages/browseros-agent/apps/cli/go.mod @@ -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 ) diff --git a/packages/browseros-agent/apps/cli/go.sum b/packages/browseros-agent/apps/cli/go.sum index c874ad6b5..e142e60d4 100644 --- a/packages/browseros-agent/apps/cli/go.sum +++ b/packages/browseros-agent/apps/cli/go.sum @@ -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= diff --git a/packages/browseros-agent/apps/cli/update/apply.go b/packages/browseros-agent/apps/cli/update/apply.go new file mode 100644 index 000000000..fe3d5a602 --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/apply.go @@ -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) +} diff --git a/packages/browseros-agent/apps/cli/update/archive.go b/packages/browseros-agent/apps/cli/update/archive.go new file mode 100644 index 000000000..c022996d5 --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/archive.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/update/archive_test.go b/packages/browseros-agent/apps/cli/update/archive_test.go new file mode 100644 index 000000000..eda5d9aab --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/archive_test.go @@ -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() +} diff --git a/packages/browseros-agent/apps/cli/update/manager.go b/packages/browseros-agent/apps/cli/update/manager.go new file mode 100644 index 000000000..118004bcc --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/manager.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/update/manager_test.go b/packages/browseros-agent/apps/cli/update/manager_test.go new file mode 100644 index 000000000..230198c71 --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/manager_test.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/update/manifest.go b/packages/browseros-agent/apps/cli/update/manifest.go new file mode 100644 index 000000000..7b858f663 --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/manifest.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/update/manifest_test.go b/packages/browseros-agent/apps/cli/update/manifest_test.go new file mode 100644 index 000000000..d9e010a9d --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/manifest_test.go @@ -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") + } +} diff --git a/packages/browseros-agent/apps/cli/update/state.go b/packages/browseros-agent/apps/cli/update/state.go new file mode 100644 index 000000000..e602e0f6d --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/state.go @@ -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 +} diff --git a/packages/browseros-agent/apps/cli/update/state_test.go b/packages/browseros-agent/apps/cli/update/state_test.go new file mode 100644 index 000000000..644d3ce16 --- /dev/null +++ b/packages/browseros-agent/apps/cli/update/state_test.go @@ -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") + } +} diff --git a/packages/browseros-agent/scripts/build/cli/upload.test.ts b/packages/browseros-agent/scripts/build/cli/upload.test.ts new file mode 100644 index 000000000..7217fcd05 --- /dev/null +++ b/packages/browseros-agent/scripts/build/cli/upload.test.ts @@ -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') + }) +}) diff --git a/packages/browseros-agent/scripts/build/cli/upload.ts b/packages/browseros-agent/scripts/build/cli/upload.ts index abd2edf00..3e49291a1 100644 --- a/packages/browseros-agent/scripts/build/cli/upload.ts +++ b/packages/browseros-agent/scripts/build/cli/upload.ts @@ -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_(?[^_]+)_(?darwin|linux|windows)_(?amd64|arm64)\.(?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 +} + +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 { } } +export function parseCliChecksums(contents: string): Map { + const entries = new Map() + 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 = {} + 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, + version: string, + releaseArchives: string[], + uploadPrefix: string, + absoluteBinariesDir: string, + r2: CliUploadConfig['r2'], +): Promise { + 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')