mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 15:46:22 +00:00
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:
@@ -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 }}"
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
179
packages/browseros-agent/apps/cli/cmd/update.go
Normal file
179
packages/browseros-agent/apps/cli/cmd/update.go
Normal 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
|
||||
}
|
||||
176
packages/browseros-agent/apps/cli/cmd/update_test.go
Normal file
176
packages/browseros-agent/apps/cli/cmd/update_test.go
Normal 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(),
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
49
packages/browseros-agent/apps/cli/update/apply.go
Normal file
49
packages/browseros-agent/apps/cli/update/apply.go
Normal 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)
|
||||
}
|
||||
138
packages/browseros-agent/apps/cli/update/archive.go
Normal file
138
packages/browseros-agent/apps/cli/update/archive.go
Normal 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
|
||||
}
|
||||
168
packages/browseros-agent/apps/cli/update/archive_test.go
Normal file
168
packages/browseros-agent/apps/cli/update/archive_test.go
Normal 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()
|
||||
}
|
||||
249
packages/browseros-agent/apps/cli/update/manager.go
Normal file
249
packages/browseros-agent/apps/cli/update/manager.go
Normal 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
|
||||
}
|
||||
162
packages/browseros-agent/apps/cli/update/manager_test.go
Normal file
162
packages/browseros-agent/apps/cli/update/manager_test.go
Normal 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
|
||||
}
|
||||
144
packages/browseros-agent/apps/cli/update/manifest.go
Normal file
144
packages/browseros-agent/apps/cli/update/manifest.go
Normal 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
|
||||
}
|
||||
102
packages/browseros-agent/apps/cli/update/manifest_test.go
Normal file
102
packages/browseros-agent/apps/cli/update/manifest_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
80
packages/browseros-agent/apps/cli/update/state.go
Normal file
80
packages/browseros-agent/apps/cli/update/state.go
Normal 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
|
||||
}
|
||||
54
packages/browseros-agent/apps/cli/update/state_test.go
Normal file
54
packages/browseros-agent/apps/cli/update/state_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
100
packages/browseros-agent/scripts/build/cli/upload.test.ts
Normal file
100
packages/browseros-agent/scripts/build/cli/upload.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user