mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-14 08:03:58 +00:00
Compare commits
2 Commits
browseros-
...
fix/cli-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a36c09577 | ||
|
|
2ce6379f8c |
@@ -1,4 +1,4 @@
|
||||
name: Release BrowserOS Extension
|
||||
name: Release Agent Extension
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
run: |
|
||||
TAG="agent-extension-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="BrowserOS Extension - v${{ steps.version.outputs.version }}"
|
||||
TITLE="BrowserOS Agent Extension v${{ steps.version.outputs.version }}"
|
||||
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
echo "Tag $TAG already exists, skipping tag creation"
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent extension changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for BrowserOS Extension v${VERSION}." \
|
||||
--body "Auto-generated changelog update for BrowserOS Agent Extension v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
|
||||
6
.github/workflows/release-agent-sdk.yml
vendored
6
.github/workflows/release-agent-sdk.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release BrowserOS Agent SDK
|
||||
name: Release Agent SDK
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
run: |
|
||||
TAG="agent-sdk-v${{ steps.version.outputs.version }}"
|
||||
RELEASE_SHA="${{ steps.version.outputs.release_sha }}"
|
||||
TITLE="BrowserOS Agent SDK - v${{ steps.version.outputs.version }}"
|
||||
TITLE="@browseros-ai/agent-sdk v${{ steps.version.outputs.version }}"
|
||||
|
||||
# Create or reuse tag (idempotent for re-runs)
|
||||
if git rev-parse "$TAG" >/dev/null 2>&1; then
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
|
||||
gh pr create \
|
||||
--title "docs: update agent-sdk changelog for v${VERSION}" \
|
||||
--body "Auto-generated changelog update for BrowserOS Agent SDK v${VERSION}." \
|
||||
--body "Auto-generated changelog update for @browseros-ai/agent-sdk v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
|
||||
|
||||
37
.github/workflows/release-cli.yml
vendored
37
.github/workflows/release-cli.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release BrowserOS CLI
|
||||
name: Release CLI
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -38,13 +38,40 @@ jobs:
|
||||
bun-version: "1.3.6"
|
||||
|
||||
- name: Run tests
|
||||
run: make test
|
||||
run: go test ./... -v
|
||||
|
||||
- name: Run vet
|
||||
run: make vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Build all platforms
|
||||
run: make release VERSION=${{ inputs.version }} POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}
|
||||
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"
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
@@ -134,7 +161,7 @@ jobs:
|
||||
|
||||
CLI_DIST="packages/browseros-agent/apps/cli/dist"
|
||||
gh release create "$TAG" \
|
||||
--title "BrowserOS CLI - v${{ inputs.version }}" \
|
||||
--title "browseros-cli v${{ inputs.version }}" \
|
||||
--notes-file /tmp/release-notes.md \
|
||||
${CLI_DIST}/*
|
||||
working-directory: ${{ github.workspace }}
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
# BrowserOS Agent Extension
|
||||
|
||||
## v0.0.98 (2026-03-27)
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: update agent version (#608)
|
||||
- chore: fix version number for extension (#606)
|
||||
- fix: improve chat history freshness and reduce query payload (#598)
|
||||
- feat: isolate new-tab agent navigation from origin tab (#593)
|
||||
- docs: overhaul READMEs across all major packages (#594)
|
||||
- fix(ui): resolve MCP promo banner dismiss button overlapping with text (#581)
|
||||
- docs: update agent extension changelog for v0.0.52 (#573)
|
||||
|
||||
|
||||
## v0.0.52 (2026-03-26)
|
||||
|
||||
Initial release
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@browseros/agent",
|
||||
"description": "manifest.json description",
|
||||
"private": true,
|
||||
"version": "0.0.98",
|
||||
"version": "0.0.52",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "test -d generated/graphql || bun run codegen; mkdir -p /tmp/browseros-dev; bun --env-file=.env.development wxt",
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
# Production build env for CLI
|
||||
# Production upload env for CLI installer scripts
|
||||
|
||||
POSTHOG_API_KEY=
|
||||
|
||||
# Upload env for CLI installer scripts
|
||||
R2_ACCOUNT_ID=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
|
||||
50
packages/browseros-agent/apps/cli/.goreleaser.yml
Normal file
50
packages/browseros-agent/apps/cli/.goreleaser.yml
Normal file
@@ -0,0 +1,50 @@
|
||||
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 }}
|
||||
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 }}"
|
||||
@@ -1,23 +1,17 @@
|
||||
BINARY := browseros-cli
|
||||
SOURCES := $(shell find . -name '*.go')
|
||||
VERSION ?= dev
|
||||
POSTHOG_API_KEY ?=
|
||||
LDFLAGS := -X main.version=$(VERSION) -X browseros-cli/analytics.posthogAPIKey=$(POSTHOG_API_KEY)
|
||||
|
||||
$(BINARY): $(SOURCES)
|
||||
go build -ldflags "$(LDFLAGS)" -o $(BINARY) .
|
||||
go build -ldflags "-X main.version=$(VERSION)" -o $(BINARY) .
|
||||
|
||||
PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64
|
||||
DIST := dist
|
||||
|
||||
.PHONY: install clean vet test release
|
||||
.PHONY: install clean vet test
|
||||
|
||||
install:
|
||||
go install -ldflags "$(LDFLAGS)" .
|
||||
go install -ldflags "-X main.version=$(VERSION)" .
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)
|
||||
rm -rf $(DIST)
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
@@ -25,26 +19,8 @@ vet:
|
||||
test:
|
||||
go test -tags integration -v -timeout 120s ./...
|
||||
|
||||
release-dry:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
release:
|
||||
@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)
|
||||
goreleaser release --clean
|
||||
|
||||
@@ -54,16 +54,6 @@ 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,129 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browseros-cli/config"
|
||||
|
||||
"github.com/posthog/posthog-go"
|
||||
)
|
||||
|
||||
var (
|
||||
posthogAPIKey string // set via ldflags
|
||||
posthogHost = "https://us.i.posthog.com"
|
||||
)
|
||||
|
||||
const eventPrefix = "browseros.cli."
|
||||
|
||||
var svc *service
|
||||
|
||||
type service struct {
|
||||
client posthog.Client
|
||||
distinctID string
|
||||
}
|
||||
|
||||
func Init(version string) {
|
||||
if posthogAPIKey == "" {
|
||||
return
|
||||
}
|
||||
|
||||
distinctID := resolveDistinctID()
|
||||
if distinctID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
client, err := posthog.NewWithConfig(posthogAPIKey, posthog.Config{
|
||||
Endpoint: posthogHost,
|
||||
BatchSize: 10,
|
||||
ShutdownTimeout: 3 * time.Second,
|
||||
DefaultEventProperties: posthog.NewProperties().
|
||||
Set("cli_version", version).
|
||||
Set("os", runtime.GOOS).
|
||||
Set("arch", runtime.GOARCH),
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
svc = &service{client: client, distinctID: distinctID}
|
||||
}
|
||||
|
||||
func Track(command string, success bool, duration time.Duration) {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.client.Enqueue(posthog.Capture{
|
||||
DistinctId: svc.distinctID,
|
||||
Event: eventPrefix + "command_executed",
|
||||
Properties: posthog.NewProperties().
|
||||
Set("command", command).
|
||||
Set("success", success).
|
||||
Set("duration_ms", duration.Milliseconds()).
|
||||
Set("$process_person_profile", false),
|
||||
})
|
||||
}
|
||||
|
||||
func Close() {
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
svc.client.Close()
|
||||
svc = nil
|
||||
}
|
||||
|
||||
func resolveDistinctID() string {
|
||||
if id := loadBrowserosID(); id != "" {
|
||||
return id
|
||||
}
|
||||
return loadOrCreateInstallID()
|
||||
}
|
||||
|
||||
func loadBrowserosID() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(home, ".browseros", "server.json"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var sc struct {
|
||||
BrowserosID string `json:"browseros_id"`
|
||||
}
|
||||
if json.Unmarshal(data, &sc) != nil {
|
||||
return ""
|
||||
}
|
||||
return sc.BrowserosID
|
||||
}
|
||||
|
||||
func loadOrCreateInstallID() string {
|
||||
dir := config.Dir()
|
||||
idPath := filepath.Join(dir, "install_id")
|
||||
|
||||
data, err := os.ReadFile(idPath)
|
||||
if err == nil {
|
||||
if id := strings.TrimSpace(string(data)); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
|
||||
id := generateUUID()
|
||||
os.MkdirAll(dir, 0755)
|
||||
os.WriteFile(idPath, []byte(id), 0644)
|
||||
return id
|
||||
}
|
||||
|
||||
func generateUUID() string {
|
||||
var b [16]byte
|
||||
rand.Read(b[:])
|
||||
b[6] = (b[6] & 0x0f) | 0x40 // version 4
|
||||
b[8] = (b[8] & 0x3f) | 0x80 // variant 2
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package analytics
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateUUID(t *testing.T) {
|
||||
id := generateUUID()
|
||||
uuidRe := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
|
||||
if !uuidRe.MatchString(id) {
|
||||
t.Errorf("generateUUID() = %q, does not match UUID v4 pattern", id)
|
||||
}
|
||||
|
||||
id2 := generateUUID()
|
||||
if id == id2 {
|
||||
t.Error("generateUUID() returned the same value twice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBrowserosID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
|
||||
// No server.json → empty
|
||||
if got := loadBrowserosID(); got != "" {
|
||||
t.Errorf("loadBrowserosID() = %q, want empty", got)
|
||||
}
|
||||
|
||||
// server.json without browseros_id → empty
|
||||
dir := filepath.Join(tmp, ".browseros")
|
||||
os.MkdirAll(dir, 0755)
|
||||
data, _ := json.Marshal(map[string]any{"server_port": 9100, "url": "http://127.0.0.1:9100"})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
if got := loadBrowserosID(); got != "" {
|
||||
t.Errorf("loadBrowserosID() = %q, want empty (no browseros_id field)", got)
|
||||
}
|
||||
|
||||
// server.json with browseros_id → returns it
|
||||
data, _ = json.Marshal(map[string]any{
|
||||
"server_port": 9100,
|
||||
"url": "http://127.0.0.1:9100",
|
||||
"browseros_id": "test-uuid-1234",
|
||||
})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
if got := loadBrowserosID(); got != "test-uuid-1234" {
|
||||
t.Errorf("loadBrowserosID() = %q, want %q", got, "test-uuid-1234")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOrCreateInstallID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
configDir := filepath.Join(tmp, "browseros-cli")
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// First call creates the file
|
||||
id := loadOrCreateInstallID()
|
||||
if id == "" {
|
||||
t.Fatal("loadOrCreateInstallID() returned empty string")
|
||||
}
|
||||
|
||||
// File was persisted
|
||||
data, err := os.ReadFile(filepath.Join(configDir, "install_id"))
|
||||
if err != nil {
|
||||
t.Fatalf("install_id file not created: %v", err)
|
||||
}
|
||||
if string(data) != id {
|
||||
t.Errorf("persisted id = %q, want %q", string(data), id)
|
||||
}
|
||||
|
||||
// Second call returns the same ID
|
||||
id2 := loadOrCreateInstallID()
|
||||
if id2 != id {
|
||||
t.Errorf("loadOrCreateInstallID() = %q, want stable %q", id2, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistinctID_PrefersBrowserosID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// Write server.json with browseros_id
|
||||
dir := filepath.Join(tmp, ".browseros")
|
||||
os.MkdirAll(dir, 0755)
|
||||
data, _ := json.Marshal(map[string]any{"browseros_id": "server-uuid"})
|
||||
os.WriteFile(filepath.Join(dir, "server.json"), data, 0644)
|
||||
|
||||
got := resolveDistinctID()
|
||||
if got != "server-uuid" {
|
||||
t.Errorf("resolveDistinctID() = %q, want %q", got, "server-uuid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDistinctID_FallsBackToInstallID(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("HOME", tmp)
|
||||
t.Setenv("XDG_CONFIG_HOME", tmp)
|
||||
|
||||
// No server.json → should generate install_id
|
||||
got := resolveDistinctID()
|
||||
if got == "" {
|
||||
t.Error("resolveDistinctID() returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitNoopsWithoutAPIKey(t *testing.T) {
|
||||
old := posthogAPIKey
|
||||
posthogAPIKey = ""
|
||||
defer func() { posthogAPIKey = old }()
|
||||
|
||||
Init("1.0.0")
|
||||
if svc != nil {
|
||||
t.Error("Init() created service without API key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackAndCloseNoopWithoutInit(t *testing.T) {
|
||||
old := svc
|
||||
svc = nil
|
||||
defer func() { svc = old }()
|
||||
|
||||
// Should not panic
|
||||
Track("test", true, time.Second)
|
||||
Close()
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -10,11 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"browseros-cli/analytics"
|
||||
"browseros-cli/config"
|
||||
"browseros-cli/mcp"
|
||||
"browseros-cli/output"
|
||||
"browseros-cli/update"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -30,8 +27,6 @@ var (
|
||||
version = "dev"
|
||||
)
|
||||
|
||||
const automaticUpdateDrainTimeout = 150 * time.Millisecond
|
||||
|
||||
func SetVersion(v string) {
|
||||
version = v
|
||||
}
|
||||
@@ -118,40 +113,11 @@ 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()
|
||||
|
||||
if err != nil {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func commandName(args []string) string {
|
||||
cmd, _, err := rootCmd.Find(args)
|
||||
if err != nil || cmd == rootCmd {
|
||||
return "unknown"
|
||||
}
|
||||
return cmd.CommandPath()
|
||||
}
|
||||
|
||||
func init() {
|
||||
cobra.AddTemplateFunc("helpHeader", helpHeader)
|
||||
cobra.AddTemplateFunc("helpCmdCol", helpCmdCol)
|
||||
@@ -200,93 +166,6 @@ 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,128 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCommandName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{"empty args", nil, "unknown"},
|
||||
{"known command", []string{"health"}, "browseros-cli health"},
|
||||
{"unknown command", []string{"nonexistent"}, "unknown"},
|
||||
{"subcommand", []string{"bookmark", "search"}, "browseros-cli bookmark search"},
|
||||
{"known with extra args", []string{"snap", "--enhanced"}, "browseros-cli snap"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := commandName(tt.args)
|
||||
if got != tt.want {
|
||||
t.Errorf("commandName(%v) = %q, want %q", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
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,28 +4,20 @@ 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
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modelcontextprotocol/go-sdk v1.4.0 // indirect
|
||||
github.com/segmentio/asm v1.1.3 // indirect
|
||||
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
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
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=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
@@ -24,14 +10,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=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posthog/posthog-go v1.11.2 h1:ApKTtOhIeWhUBc4ByO+mlbg2o0iZaEGJnJHX2QDnn5Q=
|
||||
github.com/posthog/posthog-go v1.11.2/go.mod h1:xsVOW9YImilUcazwPNEq4PJDqEZf2KeCS758zXjwkPg=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
|
||||
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
|
||||
@@ -41,39 +21,17 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
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.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* Browser Service - Direct browser operations for SDK
|
||||
*/
|
||||
|
||||
import type { Browser, PageInfo } from '../../../browser/browser'
|
||||
import type { Browser } from '../../../browser/browser'
|
||||
import type {
|
||||
ActiveTab,
|
||||
InteractiveElements,
|
||||
@@ -19,65 +19,6 @@ import { SdkError } from './types'
|
||||
export class BrowserService {
|
||||
constructor(private browser: Browser) {}
|
||||
|
||||
private selectPage(pages: PageInfo[], windowId?: number): PageInfo | null {
|
||||
const scopedPages =
|
||||
windowId === undefined
|
||||
? pages
|
||||
: pages.filter((page) => page.windowId === windowId)
|
||||
if (scopedPages.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
scopedPages.find((page) => page.isActive) ??
|
||||
scopedPages.find((page) => !page.isHidden) ??
|
||||
scopedPages[0]
|
||||
)
|
||||
}
|
||||
|
||||
private async findExistingPage(windowId?: number): Promise<PageInfo | null> {
|
||||
if (windowId === undefined) {
|
||||
const activePage = await this.browser.getActivePage()
|
||||
if (activePage) {
|
||||
return activePage
|
||||
}
|
||||
}
|
||||
|
||||
return this.selectPage(await this.browser.listPages(), windowId)
|
||||
}
|
||||
|
||||
private async resolveExistingPage(windowId?: number): Promise<PageInfo> {
|
||||
const page = await this.findExistingPage(windowId)
|
||||
if (!page) {
|
||||
throw new SdkError(
|
||||
windowId === undefined
|
||||
? 'No active tab found'
|
||||
: 'No tab found in specified window',
|
||||
)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
private async resolveNavigationPage(windowId?: number): Promise<PageInfo> {
|
||||
const existingPage = await this.findExistingPage(windowId)
|
||||
if (existingPage) {
|
||||
return existingPage
|
||||
}
|
||||
if (windowId !== undefined) {
|
||||
throw new SdkError('No tab found in specified window')
|
||||
}
|
||||
|
||||
const pageId = await this.browser.newPage('about:blank', {
|
||||
background: false,
|
||||
})
|
||||
const createdPage = (await this.browser.listPages()).find(
|
||||
(page) => page.pageId === pageId,
|
||||
)
|
||||
if (!createdPage) {
|
||||
throw new SdkError('Failed to create a tab for navigation')
|
||||
}
|
||||
return createdPage
|
||||
}
|
||||
|
||||
private async getPageIdForTab(tabId: number): Promise<number> {
|
||||
const resolved = await this.browser.resolveTabIds([tabId])
|
||||
const pageId = resolved.get(tabId)
|
||||
@@ -88,7 +29,26 @@ export class BrowserService {
|
||||
}
|
||||
|
||||
async getActiveTab(windowId?: number): Promise<ActiveTab> {
|
||||
const page = await this.resolveExistingPage(windowId)
|
||||
if (windowId !== undefined) {
|
||||
// Find the active tab in the specified window
|
||||
const pages = await this.browser.listPages()
|
||||
const page = pages.find((p) => p.windowId === windowId && p.isActive)
|
||||
if (!page) {
|
||||
throw new SdkError('No active tab found in specified window')
|
||||
}
|
||||
return {
|
||||
tabId: page.tabId,
|
||||
url: page.url,
|
||||
title: page.title,
|
||||
windowId: page.windowId ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const page = await this.browser.getActivePage()
|
||||
if (!page) {
|
||||
throw new SdkError('No active tab found')
|
||||
}
|
||||
|
||||
return {
|
||||
tabId: page.tabId,
|
||||
url: page.url,
|
||||
@@ -129,7 +89,20 @@ export class BrowserService {
|
||||
return { tabId, windowId: page.windowId ?? 0 }
|
||||
}
|
||||
|
||||
const activePage = await this.resolveNavigationPage(windowId)
|
||||
if (windowId !== undefined) {
|
||||
const pages = await this.browser.listPages()
|
||||
const page = pages.find((p) => p.windowId === windowId && p.isActive)
|
||||
if (!page) {
|
||||
throw new SdkError('No active tab in specified window')
|
||||
}
|
||||
await this.browser.goto(page.pageId, url)
|
||||
return { tabId: page.tabId, windowId }
|
||||
}
|
||||
|
||||
const activePage = await this.browser.getActivePage()
|
||||
if (!activePage) {
|
||||
throw new SdkError('No active tab to navigate')
|
||||
}
|
||||
await this.browser.goto(activePage.pageId, url)
|
||||
return {
|
||||
tabId: activePage.tabId,
|
||||
|
||||
@@ -116,7 +116,6 @@ export class Application {
|
||||
server_version: VERSION,
|
||||
browseros_version: this.config.instanceBrowserosVersion,
|
||||
chromium_version: this.config.instanceChromiumVersion,
|
||||
browseros_id: identity.getBrowserOSId(),
|
||||
})
|
||||
} catch (error) {
|
||||
logger.warn('Failed to write server config for auto-discovery', {
|
||||
|
||||
@@ -6,57 +6,21 @@
|
||||
* Tests the SDK against a real BrowserOS server.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, it } from 'bun:test'
|
||||
import { beforeAll, describe, it } from 'bun:test'
|
||||
import assert from 'node:assert'
|
||||
import { Agent } from '@browseros-ai/agent-sdk'
|
||||
|
||||
import { CdpBackend } from '../../src/browser/backends/cdp'
|
||||
import type { ControllerBackend } from '../../src/browser/backends/types'
|
||||
import { Browser } from '../../src/browser/browser'
|
||||
import {
|
||||
ensureBrowserOS,
|
||||
type TestEnvironmentConfig,
|
||||
} from '../__helpers__/setup'
|
||||
|
||||
let config: TestEnvironmentConfig
|
||||
let cdp: CdpBackend | null = null
|
||||
let runtimeWindowId: number
|
||||
|
||||
const stubController: ControllerBackend = {
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
isConnected: () => false,
|
||||
send: async () => {
|
||||
throw new Error('Controller not available in SDK tests')
|
||||
},
|
||||
}
|
||||
|
||||
async function getRuntimeWindow(
|
||||
testConfig: TestEnvironmentConfig,
|
||||
): Promise<number> {
|
||||
const runtimeCdp = new CdpBackend({ port: testConfig.cdpPort })
|
||||
await runtimeCdp.connect()
|
||||
cdp = runtimeCdp
|
||||
|
||||
const browser = new Browser(runtimeCdp, stubController)
|
||||
const pages = await browser.listPages()
|
||||
const page =
|
||||
pages.find((entry) => !entry.isHidden && entry.windowId !== undefined) ??
|
||||
pages.find((entry) => entry.windowId !== undefined)
|
||||
|
||||
assert.ok(page?.windowId !== undefined, 'Expected a runtime window ID')
|
||||
return page.windowId
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
config = await ensureBrowserOS()
|
||||
runtimeWindowId = await getRuntimeWindow(config)
|
||||
}, 60000)
|
||||
|
||||
afterAll(async () => {
|
||||
await cdp?.disconnect()
|
||||
})
|
||||
|
||||
function createAgent(browserContext?: {
|
||||
windowId?: number
|
||||
activeTab?: { id: number; url: string }
|
||||
@@ -213,17 +177,20 @@ describe('Agent SDK Integration', () => {
|
||||
|
||||
describe('browserContext', () => {
|
||||
it('passes windowId through nav()', async () => {
|
||||
const testWindowId = runtimeWindowId
|
||||
const testWindowId = 12345
|
||||
const agent = createAgent({ windowId: testWindowId })
|
||||
const events: unknown[] = []
|
||||
agent.onProgress((event) => events.push(event))
|
||||
|
||||
// This will use the windowId from browserContext
|
||||
// Server logs should show the windowId being passed
|
||||
const result = await agent.nav('data:text/html,<h1>Window Test</h1>')
|
||||
|
||||
console.log('\n=== nav() with windowId ===')
|
||||
console.log('windowId:', testWindowId)
|
||||
console.log('result:', JSON.stringify(result, null, 2))
|
||||
|
||||
// Navigation may fail if window doesn't exist, but we're testing the flow
|
||||
assert.ok(
|
||||
typeof result.success === 'boolean',
|
||||
'Should return a result with success boolean',
|
||||
@@ -231,12 +198,14 @@ describe('Agent SDK Integration', () => {
|
||||
}, 30000)
|
||||
|
||||
it('passes windowId through act()', async () => {
|
||||
const testWindowId = runtimeWindowId
|
||||
const testWindowId = 12345
|
||||
const agent = createAgent({ windowId: testWindowId })
|
||||
|
||||
// First navigate without windowId constraint to set up the page
|
||||
const plainAgent = createAgent()
|
||||
await plainAgent.nav('data:text/html,<button id="btn">Click</button>')
|
||||
|
||||
// Now act with windowId - server logs should show windowId being passed
|
||||
const result = await agent.act('describe what you see')
|
||||
|
||||
console.log('\n=== act() with windowId ===')
|
||||
@@ -251,12 +220,14 @@ describe('Agent SDK Integration', () => {
|
||||
|
||||
it('passes windowId through extract()', async () => {
|
||||
const { z } = await import('zod')
|
||||
const testWindowId = runtimeWindowId
|
||||
const testWindowId = 12345
|
||||
const agent = createAgent({ windowId: testWindowId })
|
||||
|
||||
// Set up a page first
|
||||
const plainAgent = createAgent()
|
||||
await plainAgent.nav('data:text/html,<h1>Extract Test</h1>')
|
||||
|
||||
// Extract with windowId - server logs should show windowId
|
||||
const result = await agent.extract('get the page heading', {
|
||||
schema: z.object({ heading: z.string() }),
|
||||
})
|
||||
@@ -269,12 +240,14 @@ describe('Agent SDK Integration', () => {
|
||||
}, 60000)
|
||||
|
||||
it('passes windowId through verify()', async () => {
|
||||
const testWindowId = runtimeWindowId
|
||||
const testWindowId = 12345
|
||||
const agent = createAgent({ windowId: testWindowId })
|
||||
|
||||
// Set up a page first
|
||||
const plainAgent = createAgent()
|
||||
await plainAgent.nav('data:text/html,<h1>Verify Test</h1>')
|
||||
|
||||
// Verify with windowId - server logs should show windowId
|
||||
const result = await agent.verify('the page has some content')
|
||||
|
||||
console.log('\n=== verify() with windowId ===')
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"apps/agent": {
|
||||
"name": "@browseros/agent",
|
||||
"version": "0.0.98",
|
||||
"version": "0.0.52",
|
||||
"dependencies": {
|
||||
"@ai-sdk/react": "^3.0.96",
|
||||
"@browseros/server": "workspace:*",
|
||||
|
||||
@@ -13,5 +13,4 @@ export interface ServerDiscoveryConfig {
|
||||
server_version: string
|
||||
browseros_version?: string
|
||||
chromium_version?: string
|
||||
browseros_id?: string
|
||||
}
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
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, readFileSync } from 'node:fs'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
@@ -6,12 +6,9 @@ import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { log } from '../log'
|
||||
import { createR2Client, joinObjectKey, uploadFileToObject } from '../server/r2'
|
||||
import { type CliUploadConfig, loadCliUploadConfig } from './config'
|
||||
import { 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 = [
|
||||
{
|
||||
@@ -31,28 +28,6 @@ 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)
|
||||
@@ -95,130 +70,6 @@ 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,
|
||||
@@ -236,7 +87,6 @@ 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)
|
||||
@@ -260,15 +110,6 @@ 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