mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
15 Commits
dev
...
feat/local
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e8f1f2845 | ||
|
|
191da98714 | ||
|
|
c6fac277ee | ||
|
|
329e8b24a2 | ||
|
|
bbc4844599 | ||
|
|
70b91c0ea1 | ||
|
|
1db4d19f4b | ||
|
|
d7a83b9df2 | ||
|
|
5766b5eb7f | ||
|
|
c4ee678b00 | ||
|
|
9b27e6aece | ||
|
|
9e0252fe1c | ||
|
|
acf0ea028d | ||
|
|
88d3d9dbad | ||
|
|
31ca756aec |
2
packages/browseros-agent/.gitignore
vendored
2
packages/browseros-agent/.gitignore
vendored
@@ -180,6 +180,8 @@ packages/*/dist
|
||||
browseros-server
|
||||
browseros-server.exe
|
||||
browseros-server-*
|
||||
tools/alpha/balpha
|
||||
tools/alpha/browseros-alpha
|
||||
tools/dev/browseros-dev
|
||||
|
||||
log.txt
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"dev:watch:new": "./tools/dev/run.sh watch --new",
|
||||
"dev:manual": "./tools/dev/run.sh watch --manual",
|
||||
"dev:setup": "./tools/dev/setup.sh",
|
||||
"install:balpha": "make -C tools/alpha install",
|
||||
"test:env": "./tools/dev/run.sh test",
|
||||
"test:cleanup": "./tools/dev/run.sh cleanup",
|
||||
"start:server": "bun run --filter @browseros/server --elide-lines=0 start",
|
||||
|
||||
21
packages/browseros-agent/tools/alpha/Makefile
Normal file
21
packages/browseros-agent/tools/alpha/Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
BINARY := balpha
|
||||
SOURCES := $(shell find . -name '*.go') go.mod go.sum
|
||||
PREFIX ?= $(HOME)/bin
|
||||
|
||||
$(BINARY): $(SOURCES)
|
||||
@echo "[build] Compiling $(BINARY)..."
|
||||
@go build -o $(BINARY) .
|
||||
|
||||
.PHONY: install test clean
|
||||
|
||||
install: $(BINARY)
|
||||
@mkdir -p $(PREFIX)
|
||||
@cp $(BINARY) $(PREFIX)/$(BINARY)
|
||||
@codesign --force --sign - $(PREFIX)/$(BINARY) >/dev/null 2>&1 || true
|
||||
@echo "Installed $(PREFIX)/$(BINARY)"
|
||||
|
||||
test:
|
||||
@go test ./...
|
||||
|
||||
clean:
|
||||
@rm -f $(BINARY)
|
||||
156
packages/browseros-agent/tools/alpha/README.md
Normal file
156
packages/browseros-agent/tools/alpha/README.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# balpha
|
||||
|
||||
Internal BrowserOS alpha dogfooding CLI for running the current checkout against a copied BrowserOS profile.
|
||||
|
||||
## What It Does
|
||||
|
||||
`balpha` starts a local BrowserOS dogfooding environment:
|
||||
|
||||
- Uses the BrowserOS repo path from config, then works from `packages/browseros-agent`.
|
||||
- Copies one installed BrowserOS profile into a separate dev profile under `~/.config/balpha/profile`.
|
||||
- Writes `apps/server/.env.production` and `apps/cli/.env.production` from config.
|
||||
- Runs the existing `tools/dev/setup.sh` setup flow.
|
||||
- Builds the WXT dev extension.
|
||||
- Launches `/Applications/BrowserOS.app` with the dev profile, the local extension, and the built-in server disabled.
|
||||
- Starts the local Bun server from `apps/server`.
|
||||
|
||||
It does not auto-pull on `start`. Use `balpha pull` when you want to refresh the checkout.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS.
|
||||
- Go.
|
||||
- Bun.
|
||||
- BrowserOS installed at `/Applications/BrowserOS.app`.
|
||||
- A BrowserOS monorepo checkout, for example `~/code/browseros-project/browseros-test`.
|
||||
- `~/bin` or your chosen install directory on `PATH`.
|
||||
|
||||
## Install
|
||||
|
||||
From the BrowserOS monorepo root:
|
||||
|
||||
```bash
|
||||
cd packages/browseros-agent
|
||||
bun run install:balpha
|
||||
```
|
||||
|
||||
This builds `tools/alpha/balpha` and installs it to `~/bin/balpha`.
|
||||
|
||||
To install somewhere else:
|
||||
|
||||
```bash
|
||||
cd packages/browseros-agent/tools/alpha
|
||||
make install PREFIX=/usr/local/bin
|
||||
```
|
||||
|
||||
Check the binary:
|
||||
|
||||
```bash
|
||||
balpha --help
|
||||
```
|
||||
|
||||
## First-Time Setup
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
balpha init
|
||||
```
|
||||
|
||||
`init` asks for:
|
||||
|
||||
- `Repo path`: the BrowserOS monorepo root, not `packages/browseros-agent`.
|
||||
- `BrowserOS binary`: defaults to `/Applications/BrowserOS.app/Contents/MacOS/BrowserOS`.
|
||||
- `Source profile`: selected from the installed BrowserOS profiles in `~/Library/Application Support/BrowserOS`.
|
||||
|
||||
Config is written to:
|
||||
|
||||
```text
|
||||
~/.config/balpha/config.yaml
|
||||
```
|
||||
|
||||
The dev profile defaults to:
|
||||
|
||||
```text
|
||||
~/.config/balpha/profile
|
||||
```
|
||||
|
||||
`init` also writes the generated production env files in the configured checkout.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
balpha start
|
||||
```
|
||||
|
||||
Each start:
|
||||
|
||||
- Warns if the configured checkout has uncommitted changes.
|
||||
- Imports the BrowserOS profile if the dev profile does not exist.
|
||||
- Rewrites production env files from config.
|
||||
- Auto-increments busy ports and saves the resolved values back to config.
|
||||
- Runs `tools/dev/setup.sh`.
|
||||
- Builds the WXT extension.
|
||||
- Starts BrowserOS and the local Bun server.
|
||||
|
||||
Use this when you want to refresh the copied profile before launching:
|
||||
|
||||
```bash
|
||||
balpha start --refresh-profile
|
||||
```
|
||||
|
||||
Use this for a headless launch:
|
||||
|
||||
```bash
|
||||
balpha start --headless
|
||||
```
|
||||
|
||||
Stop the environment with `Ctrl+C`.
|
||||
|
||||
## Update The Checkout
|
||||
|
||||
`balpha start` intentionally does not pull. To update the configured repo:
|
||||
|
||||
```bash
|
||||
balpha pull
|
||||
```
|
||||
|
||||
If the checkout has uncommitted changes, `pull` fails. To pull anyway:
|
||||
|
||||
```bash
|
||||
balpha pull --force
|
||||
```
|
||||
|
||||
## Refresh The Copied Profile
|
||||
|
||||
To overwrite the dev profile from the selected installed BrowserOS profile:
|
||||
|
||||
```bash
|
||||
balpha refresh-profile
|
||||
```
|
||||
|
||||
This removes and recreates `dev_user_data_dir`. It refuses to run if the dev user-data dir is the real BrowserOS user-data dir or lives inside it.
|
||||
|
||||
## Edit Config
|
||||
|
||||
```bash
|
||||
balpha config edit
|
||||
```
|
||||
|
||||
Important fields:
|
||||
|
||||
- `repo_path`: BrowserOS monorepo root.
|
||||
- `browseros_app_path`: BrowserOS executable to launch.
|
||||
- `source_user_data_dir`: installed BrowserOS user-data dir. Defaults to `~/Library/Application Support/BrowserOS`.
|
||||
- `source_profile_dir`: installed profile directory to copy.
|
||||
- `dev_user_data_dir`: separate dev user-data dir. Defaults to `~/.config/balpha/profile`.
|
||||
- `dev_profile_dir`: dev profile directory. Defaults to `Default`.
|
||||
- `ports`: CDP, BrowserOS server, and extension ports.
|
||||
- `production_env`: values written to `apps/server/.env.production` and `apps/cli/.env.production`.
|
||||
|
||||
## Safety Notes
|
||||
|
||||
- Do not point `dev_user_data_dir` at the real BrowserOS profile.
|
||||
- `balpha` does not pass `--use-mock-keychain`; copied login data relies on the installed signed app path.
|
||||
- Default ports are CDP `9015`, server `9115`, and extension `9315`.
|
||||
- Browser launch passes `--browseros-mcp-port`, `--browseros-server-port`, and `--browseros-proxy-port` to tolerate current switch differences.
|
||||
43
packages/browseros-agent/tools/alpha/browser/args.go
Normal file
43
packages/browseros-agent/tools/alpha/browser/args.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
type ArgsConfig struct {
|
||||
Binary string
|
||||
AgentRoot string
|
||||
UserDataDir string
|
||||
ProfileDir string
|
||||
Ports config.Ports
|
||||
Headless bool
|
||||
}
|
||||
|
||||
func BuildArgs(cfg ArgsConfig) []string {
|
||||
args := []string{
|
||||
cfg.Binary,
|
||||
"--no-first-run",
|
||||
"--no-default-browser-check",
|
||||
"--show-component-extension-options",
|
||||
"--disable-browseros-server",
|
||||
"--disable-browseros-extensions",
|
||||
fmt.Sprintf("--remote-debugging-port=%d", cfg.Ports.CDP),
|
||||
// Keep all server aliases until installed BrowserOS apps converge on one switch.
|
||||
fmt.Sprintf("--browseros-mcp-port=%d", cfg.Ports.Server),
|
||||
fmt.Sprintf("--browseros-server-port=%d", cfg.Ports.Server),
|
||||
fmt.Sprintf("--browseros-proxy-port=%d", cfg.Ports.Server),
|
||||
fmt.Sprintf("--browseros-extension-port=%d", cfg.Ports.Extension),
|
||||
fmt.Sprintf("--user-data-dir=%s", cfg.UserDataDir),
|
||||
}
|
||||
if cfg.ProfileDir != "" {
|
||||
args = append(args, fmt.Sprintf("--profile-directory=%s", cfg.ProfileDir))
|
||||
}
|
||||
args = append(args, fmt.Sprintf("--load-extension=%s", filepath.Join(cfg.AgentRoot, "apps/agent/dist/chrome-mv3-dev")))
|
||||
if cfg.Headless {
|
||||
args = append(args, "--headless=new")
|
||||
}
|
||||
return append(args, "chrome://newtab")
|
||||
}
|
||||
61
packages/browseros-agent/tools/alpha/browser/args_test.go
Normal file
61
packages/browseros-agent/tools/alpha/browser/args_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func TestBuildArgs(t *testing.T) {
|
||||
args := BuildArgs(ArgsConfig{
|
||||
Binary: "/Applications/BrowserOS.app/Contents/MacOS/BrowserOS",
|
||||
AgentRoot: "/repo/packages/browseros-agent",
|
||||
UserDataDir: "/tmp/balpha",
|
||||
ProfileDir: "Default",
|
||||
Ports: config.Ports{CDP: 9015, Server: 9115, Extension: 9315},
|
||||
})
|
||||
joined := strings.Join(args, "\n")
|
||||
for _, want := range []string{
|
||||
"--remote-debugging-port=9015",
|
||||
"--browseros-mcp-port=9115",
|
||||
"--browseros-server-port=9115",
|
||||
"--browseros-proxy-port=9115",
|
||||
"--browseros-extension-port=9315",
|
||||
"--user-data-dir=/tmp/balpha",
|
||||
"--profile-directory=Default",
|
||||
"--disable-browseros-server",
|
||||
"--disable-browseros-extensions",
|
||||
"--load-extension=/repo/packages/browseros-agent/apps/agent/dist/chrome-mv3-dev",
|
||||
"chrome://newtab",
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Fatalf("missing %s in\n%s", want, joined)
|
||||
}
|
||||
}
|
||||
if strings.Contains(joined, "--use-mock-keychain") {
|
||||
t.Fatal("must not use mock keychain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildArgsHeadless(t *testing.T) {
|
||||
args := BuildArgs(ArgsConfig{
|
||||
Binary: "/bin/browser",
|
||||
AgentRoot: "/repo/packages/browseros-agent",
|
||||
UserDataDir: "/tmp/balpha",
|
||||
Ports: config.Ports{CDP: 1, Server: 2, Extension: 3},
|
||||
Headless: true,
|
||||
})
|
||||
if !contains(args, "--headless=new") {
|
||||
t.Fatalf("missing headless arg: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(values []string, target string) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
32
packages/browseros-agent/tools/alpha/browser/cdp.go
Normal file
32
packages/browseros-agent/tools/alpha/browser/cdp.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package browser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func WaitForCDP(ctx context.Context, port int, maxAttempts int) bool {
|
||||
client := &http.Client{Timeout: time.Second}
|
||||
url := fmt.Sprintf("http://127.0.0.1:%d/json/version", port)
|
||||
|
||||
for range maxAttempts {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
resp, err := client.Get(url)
|
||||
if err == nil {
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == 200 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
54
packages/browseros-agent/tools/alpha/cmd/config.go
Normal file
54
packages/browseros-agent/tools/alpha/cmd/config.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
"browseros-alpha/config"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configCmd.AddCommand(configEditCmd)
|
||||
rootCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
var configCmd = &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage balpha config",
|
||||
}
|
||||
|
||||
var configEditCmd = &cobra.Command{
|
||||
Use: "edit",
|
||||
Short: "Open balpha config in $EDITOR",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
path, err := config.Path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := config.Defaults(home)
|
||||
if err := config.Save(path, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
editor := os.Getenv("EDITOR")
|
||||
if editor == "" {
|
||||
editor = "vi"
|
||||
}
|
||||
c := exec.Command(editor, path)
|
||||
c.Stdin = os.Stdin
|
||||
c.Stdout = os.Stdout
|
||||
c.Stderr = os.Stderr
|
||||
if err := c.Run(); err != nil {
|
||||
return fmt.Errorf("editor failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
99
packages/browseros-agent/tools/alpha/cmd/init.go
Normal file
99
packages/browseros-agent/tools/alpha/cmd/init.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"browseros-alpha/config"
|
||||
"browseros-alpha/pipeline"
|
||||
"browseros-alpha/profile"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(initCmd)
|
||||
}
|
||||
|
||||
var initCmd = &cobra.Command{
|
||||
Use: "init",
|
||||
Short: "Create or update balpha config",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := config.Defaults(home)
|
||||
if cwd, err := os.Getwd(); err == nil && looksLikeRepo(cwd) {
|
||||
cfg.RepoPath = cwd
|
||||
}
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
cfg.RepoPath = prompt(reader, "Repo path", cfg.RepoPath)
|
||||
cfg.BrowserOSAppPath = prompt(reader, "BrowserOS binary", cfg.BrowserOSAppPath)
|
||||
profiles, _ := profile.ReadProfiles(cfg.SourceUserDataDir)
|
||||
cfg.SourceProfileDir = chooseProfile(reader, profiles)
|
||||
cfg.Resolve()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
path, err := config.Path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.Save(path, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pipeline.WriteProductionEnvFiles(cfg.AgentRoot(), cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Config written: %s\nRun: balpha start\n", path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func prompt(r *bufio.Reader, label string, current string) string {
|
||||
fmt.Printf("%s [%s]: ", label, current)
|
||||
line, _ := r.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return current
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
return config.ExpandTilde(line, home)
|
||||
}
|
||||
|
||||
func chooseProfile(r *bufio.Reader, profiles []profile.BrowserProfile) string {
|
||||
if len(profiles) == 0 {
|
||||
return "Default"
|
||||
}
|
||||
fmt.Printf("Found %d BrowserOS profiles:\n", len(profiles))
|
||||
for i, p := range profiles {
|
||||
email := ""
|
||||
if p.Email != "" {
|
||||
email = " " + p.Email
|
||||
}
|
||||
fmt.Printf(" %d. %s (%s)%s\n", i+1, p.Name, p.Dir, email)
|
||||
}
|
||||
for {
|
||||
fmt.Print("Select source profile [1]: ")
|
||||
line, _ := r.ReadString('\n')
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return profiles[0].Dir
|
||||
}
|
||||
n, err := strconv.Atoi(line)
|
||||
if err == nil && n >= 1 && n <= len(profiles) {
|
||||
return profiles[n-1].Dir
|
||||
}
|
||||
fmt.Println("Choose a listed number.")
|
||||
}
|
||||
}
|
||||
|
||||
func looksLikeRepo(path string) bool {
|
||||
_, err := os.Stat(filepath.Join(path, "packages/browseros-agent/package.json"))
|
||||
return err == nil
|
||||
}
|
||||
47
packages/browseros-agent/tools/alpha/cmd/pull.go
Normal file
47
packages/browseros-agent/tools/alpha/cmd/pull.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"browseros-alpha/pipeline"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var pullForce bool
|
||||
|
||||
func init() {
|
||||
pullCmd.Flags().BoolVar(&pullForce, "force", false, "Pull even when the checkout has uncommitted changes")
|
||||
rootCmd.AddCommand(pullCmd)
|
||||
}
|
||||
|
||||
var pullCmd = &cobra.Command{
|
||||
Use: "pull",
|
||||
Short: "Refresh the configured BrowserOS checkout",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runner := pipeline.ExecRunner{}
|
||||
if err := pipeline.WriteProductionEnvFiles(cfg.AgentRoot(), cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
branch := pipeline.Branch(cfg.RepoPath, runner)
|
||||
head, _ := pipeline.Head(cfg.RepoPath, runner)
|
||||
fmt.Printf("Repo: %s %s %s\n", cfg.RepoPath, branch, head)
|
||||
dirty, err := pipeline.Dirty(cfg.RepoPath, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dirty && !pullForce {
|
||||
return fmt.Errorf("checkout has uncommitted changes; commit/stash them or use --force")
|
||||
}
|
||||
if err := pipeline.Pull(cfg.RepoPath, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
newHead, _ := pipeline.Head(cfg.RepoPath, runner)
|
||||
fmt.Printf("Updated to %s\n", newHead)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
50
packages/browseros-agent/tools/alpha/cmd/refresh_profile.go
Normal file
50
packages/browseros-agent/tools/alpha/cmd/refresh_profile.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"browseros-alpha/config"
|
||||
"browseros-alpha/profile"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(refreshProfileCmd)
|
||||
}
|
||||
|
||||
var refreshProfileCmd = &cobra.Command{
|
||||
Use: "refresh-profile",
|
||||
Short: "Copy the configured BrowserOS profile into the balpha dev profile",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := profile.Import(profile.ImportConfig{
|
||||
SourceUserDataDir: cfg.SourceUserDataDir,
|
||||
SourceProfileDir: cfg.SourceProfileDir,
|
||||
DevUserDataDir: cfg.DevUserDataDir,
|
||||
DevProfileDir: cfg.DevProfileDir,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("Profile refreshed: %s\n", cfg.DevUserDataDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func loadConfig() (config.Config, error) {
|
||||
path, err := config.Path()
|
||||
if err != nil {
|
||||
return config.Config{}, err
|
||||
}
|
||||
cfg, err := config.Load(path)
|
||||
if err != nil {
|
||||
return config.Config{}, fmt.Errorf("missing config at %s; run balpha init: %w", path, err)
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return config.Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
24
packages/browseros-agent/tools/alpha/cmd/root.go
Normal file
24
packages/browseros-agent/tools/alpha/cmd/root.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "balpha",
|
||||
Short: "BrowserOS alpha dogfooding CLI",
|
||||
Long: "balpha - BrowserOS alpha dogfooding CLI",
|
||||
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
|
||||
SilenceUsage: true,
|
||||
SilenceErrors: true,
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
173
packages/browseros-agent/tools/alpha/cmd/start.go
Normal file
173
packages/browseros-agent/tools/alpha/cmd/start.go
Normal file
@@ -0,0 +1,173 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"browseros-alpha/browser"
|
||||
"browseros-alpha/config"
|
||||
"browseros-alpha/pipeline"
|
||||
"browseros-alpha/proc"
|
||||
"browseros-alpha/profile"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var startRefreshProfile bool
|
||||
var startHeadless bool
|
||||
|
||||
func init() {
|
||||
startCmd.Flags().BoolVar(&startRefreshProfile, "refresh-profile", false, "Refresh copied BrowserOS profile before launch")
|
||||
startCmd.Flags().BoolVar(&startHeadless, "headless", false, "Run BrowserOS headless")
|
||||
rootCmd.AddCommand(startCmd)
|
||||
}
|
||||
|
||||
var startCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start BrowserOS alpha dogfooding environment",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
agentRoot := cfg.AgentRoot()
|
||||
runner := pipeline.ExecRunner{}
|
||||
if dirty, err := pipeline.Dirty(cfg.RepoPath, runner); err == nil && dirty {
|
||||
fmt.Fprintln(os.Stderr, "warning: checkout has uncommitted changes; start will use current files")
|
||||
}
|
||||
if startRefreshProfile || !exists(cfg.DevUserDataDir) {
|
||||
if err := profile.Import(profile.ImportConfig{
|
||||
SourceUserDataDir: cfg.SourceUserDataDir,
|
||||
SourceProfileDir: cfg.SourceProfileDir,
|
||||
DevUserDataDir: cfg.DevUserDataDir,
|
||||
DevProfileDir: cfg.DevProfileDir,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err := profile.CleanupSingletons(cfg.DevUserDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pipeline.WriteProductionEnvFiles(agentRoot, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
resolvedPorts, changed, err := proc.ResolvePorts(cfg.Ports)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.Ports = resolvedPorts
|
||||
if changed {
|
||||
path, err := config.Path()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := config.Save(path, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
proc.LogMsgf(proc.TagInfo, "Busy ports detected; using CDP=%d Server=%d Extension=%d", cfg.Ports.CDP, cfg.Ports.Server, cfg.Ports.Extension)
|
||||
} else {
|
||||
proc.LogMsgf(proc.TagInfo, "Using ports CDP=%d Server=%d Extension=%d", cfg.Ports.CDP, cfg.Ports.Server, cfg.Ports.Extension)
|
||||
}
|
||||
if err := pipeline.Build(agentRoot, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
return runEnvironment(cfg, agentRoot)
|
||||
},
|
||||
}
|
||||
|
||||
func runEnvironment(cfg config.Config, agentRoot string) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var managed []*proc.ManagedProc
|
||||
managed = append(managed, proc.StartManaged(ctx, &wg, proc.ProcConfig{
|
||||
Tag: proc.TagBrowser,
|
||||
Dir: agentRoot,
|
||||
Restart: false,
|
||||
Cmd: browser.BuildArgs(browser.ArgsConfig{
|
||||
Binary: cfg.BrowserOSAppPath,
|
||||
AgentRoot: agentRoot,
|
||||
UserDataDir: cfg.DevUserDataDir,
|
||||
ProfileDir: cfg.DevProfileDir,
|
||||
Ports: cfg.Ports,
|
||||
Headless: startHeadless,
|
||||
}),
|
||||
}))
|
||||
proc.LogMsg(proc.TagServer, "Waiting for CDP...")
|
||||
if browser.WaitForCDP(ctx, cfg.Ports.CDP, 60) {
|
||||
proc.LogMsg(proc.TagServer, "CDP ready")
|
||||
} else {
|
||||
proc.LogMsg(proc.TagServer, proc.WarnColor.Sprint("CDP not available, starting server anyway"))
|
||||
}
|
||||
env := os.Environ()
|
||||
env = append(env,
|
||||
"NODE_ENV=development",
|
||||
fmt.Sprintf("BROWSEROS_CDP_PORT=%d", cfg.Ports.CDP),
|
||||
fmt.Sprintf("BROWSEROS_SERVER_PORT=%d", cfg.Ports.Server),
|
||||
fmt.Sprintf("BROWSEROS_EXTENSION_PORT=%d", cfg.Ports.Extension),
|
||||
fmt.Sprintf("VITE_BROWSEROS_SERVER_PORT=%d", cfg.Ports.Server),
|
||||
)
|
||||
serverDir := filepath.Join(agentRoot, "apps/server")
|
||||
managed = append(managed, proc.StartManaged(ctx, &wg, proc.ProcConfig{
|
||||
Tag: proc.TagServer,
|
||||
Dir: serverDir,
|
||||
Env: env,
|
||||
Restart: true,
|
||||
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
|
||||
}))
|
||||
printSummary(cfg, agentRoot)
|
||||
|
||||
sigCh := make(chan os.Signal, 2)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
<-sigCh
|
||||
fmt.Println()
|
||||
proc.LogMsg(proc.TagInfo, proc.WarnColor.Sprint("Shutting down (Ctrl+C again to force)..."))
|
||||
cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
go func() {
|
||||
select {
|
||||
case <-sigCh:
|
||||
for _, p := range managed {
|
||||
p.ForceKill()
|
||||
}
|
||||
os.Exit(1)
|
||||
case <-done:
|
||||
}
|
||||
}()
|
||||
for _, p := range managed {
|
||||
p.Stop()
|
||||
}
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
for _, p := range managed {
|
||||
p.ForceKill()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func printSummary(cfg config.Config, agentRoot string) {
|
||||
fmt.Println()
|
||||
proc.LogMsgf(proc.TagInfo, "App: %s", cfg.BrowserOSAppPath)
|
||||
proc.LogMsgf(proc.TagInfo, "Repo: %s", cfg.RepoPath)
|
||||
proc.LogMsgf(proc.TagInfo, "Agent root: %s", agentRoot)
|
||||
proc.LogMsgf(proc.TagInfo, "Profile: %s", cfg.DevUserDataDir)
|
||||
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", cfg.Ports.CDP, cfg.Ports.Server, cfg.Ports.Extension)
|
||||
fmt.Println()
|
||||
}
|
||||
229
packages/browseros-agent/tools/alpha/config/config.go
Normal file
229
packages/browseros-agent/tools/alpha/config/config.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"browseros-alpha/internal/fspath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Ports struct {
|
||||
CDP int `yaml:"cdp"`
|
||||
Server int `yaml:"server"`
|
||||
Extension int `yaml:"extension"`
|
||||
}
|
||||
|
||||
type ProductionEnv struct {
|
||||
Server map[string]string `yaml:"server"`
|
||||
CLI map[string]string `yaml:"cli"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
RepoPath string `yaml:"repo_path"`
|
||||
BrowserOSAppPath string `yaml:"browseros_app_path"`
|
||||
SourceUserDataDir string `yaml:"source_user_data_dir"`
|
||||
SourceProfileDir string `yaml:"source_profile_dir"`
|
||||
DevUserDataDir string `yaml:"dev_user_data_dir"`
|
||||
DevProfileDir string `yaml:"dev_profile_dir"`
|
||||
Ports Ports `yaml:"ports"`
|
||||
ProductionEnv ProductionEnv `yaml:"production_env"`
|
||||
}
|
||||
|
||||
type packageJSON struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func Path() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(DefaultConfigDir(home), "config.yaml"), nil
|
||||
}
|
||||
|
||||
func DefaultConfigDir(home string) string {
|
||||
if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" {
|
||||
return filepath.Join(xdg, "balpha")
|
||||
}
|
||||
return filepath.Join(home, ".config", "balpha")
|
||||
}
|
||||
|
||||
func Defaults(home string) Config {
|
||||
return Config{
|
||||
BrowserOSAppPath: "/Applications/BrowserOS.app/Contents/MacOS/BrowserOS",
|
||||
SourceUserDataDir: filepath.Join(home, "Library/Application Support/BrowserOS"),
|
||||
SourceProfileDir: "Default",
|
||||
DevUserDataDir: filepath.Join(DefaultConfigDir(home), "profile"),
|
||||
DevProfileDir: "Default",
|
||||
Ports: Ports{CDP: 9015, Server: 9115, Extension: 9315},
|
||||
ProductionEnv: DefaultProductionEnv(),
|
||||
}
|
||||
}
|
||||
|
||||
func Load(path string) (Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
cfg.Resolve()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func Save(path string, cfg Config) error {
|
||||
cfg.FillProductionEnvDefaults()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := "# balpha configuration\n# Run: balpha init to reconfigure\n\n"
|
||||
return os.WriteFile(path, append([]byte(header), data...), 0644)
|
||||
}
|
||||
|
||||
func (c *Config) Resolve() {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
home = ""
|
||||
}
|
||||
c.RepoPath = ExpandTilde(c.RepoPath, home)
|
||||
c.SourceUserDataDir = ExpandTilde(c.SourceUserDataDir, home)
|
||||
c.DevUserDataDir = ExpandTilde(c.DevUserDataDir, home)
|
||||
c.BrowserOSAppPath = ExpandTilde(c.BrowserOSAppPath, home)
|
||||
if c.DevProfileDir == "" {
|
||||
c.DevProfileDir = "Default"
|
||||
}
|
||||
if c.Ports.CDP == 0 {
|
||||
c.Ports.CDP = 9015
|
||||
}
|
||||
if c.Ports.Server == 0 {
|
||||
c.Ports.Server = 9115
|
||||
}
|
||||
if c.Ports.Extension == 0 {
|
||||
c.Ports.Extension = 9315
|
||||
}
|
||||
c.FillProductionEnvDefaults()
|
||||
}
|
||||
|
||||
func (c Config) AgentRoot() string {
|
||||
return filepath.Join(c.RepoPath, "packages/browseros-agent")
|
||||
}
|
||||
|
||||
func (c Config) SourceProfilePath() string {
|
||||
return filepath.Join(c.SourceUserDataDir, c.SourceProfileDir)
|
||||
}
|
||||
|
||||
func (c Config) DevProfilePath() string {
|
||||
return filepath.Join(c.DevUserDataDir, c.DevProfileDir)
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.RepoPath == "" {
|
||||
return fmt.Errorf("repo_path is required")
|
||||
}
|
||||
if c.BrowserOSAppPath == "" {
|
||||
return fmt.Errorf("browseros_app_path is required")
|
||||
}
|
||||
if c.SourceUserDataDir == "" || c.SourceProfileDir == "" {
|
||||
return fmt.Errorf("source_user_data_dir and source_profile_dir are required")
|
||||
}
|
||||
if c.DevUserDataDir == "" || c.DevProfileDir == "" {
|
||||
return fmt.Errorf("dev_user_data_dir and dev_profile_dir are required")
|
||||
}
|
||||
if fspath.IsSameOrChild(c.DevUserDataDir, c.SourceUserDataDir) {
|
||||
return fmt.Errorf("dev_user_data_dir must not equal or live inside source_user_data_dir")
|
||||
}
|
||||
if err := validateRepo(c.AgentRoot()); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Stat(c.BrowserOSAppPath); err != nil {
|
||||
return fmt.Errorf("browseros_app_path: %w", err)
|
||||
} else if info.IsDir() || info.Mode()&0111 == 0 {
|
||||
return fmt.Errorf("browseros_app_path is not an executable file: %s", c.BrowserOSAppPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRepo(agentRoot string) error {
|
||||
data, err := os.ReadFile(filepath.Join(agentRoot, "package.json"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("repo_path must contain packages/browseros-agent/package.json: %w", err)
|
||||
}
|
||||
var pkg packageJSON
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return fmt.Errorf("parse package.json: %w", err)
|
||||
}
|
||||
if pkg.Name != "browseros-monorepo" {
|
||||
return fmt.Errorf("unexpected package name %q in packages/browseros-agent/package.json", pkg.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ExpandTilde(path string, home string) string {
|
||||
if path == "~" {
|
||||
return home
|
||||
}
|
||||
if strings.HasPrefix(path, "~/") {
|
||||
return filepath.Join(home, path[2:])
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func DefaultProductionEnv() ProductionEnv {
|
||||
return ProductionEnv{
|
||||
Server: map[string]string{
|
||||
"BROWSEROS_CONFIG_URL": "https://llm.browseros.com/api/browseros-server/config",
|
||||
"CODEGEN_SERVICE_URL": "",
|
||||
"POSTHOG_API_KEY": "",
|
||||
"SENTRY_DSN": "",
|
||||
"BROWSEROS_VM_CACHE_PREFETCH": "true",
|
||||
"BROWSEROS_VM_CACHE_MANIFEST_URL": "https://cdn.browseros.com/vm/manifest.json",
|
||||
"R2_ACCOUNT_ID": "",
|
||||
"R2_ACCESS_KEY_ID": "",
|
||||
"R2_SECRET_ACCESS_KEY": "",
|
||||
"R2_BUCKET": "",
|
||||
"R2_DOWNLOAD_PREFIX": "artifacts/vendor",
|
||||
"R2_UPLOAD_PREFIX": "artifacts/server",
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info",
|
||||
},
|
||||
CLI: map[string]string{
|
||||
"POSTHOG_API_KEY": "",
|
||||
"R2_ACCOUNT_ID": "",
|
||||
"R2_ACCESS_KEY_ID": "",
|
||||
"R2_SECRET_ACCESS_KEY": "",
|
||||
"R2_BUCKET": "browseros",
|
||||
"R2_UPLOAD_PREFIX": "cli",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) FillProductionEnvDefaults() {
|
||||
defaults := DefaultProductionEnv()
|
||||
if c.ProductionEnv.Server == nil {
|
||||
c.ProductionEnv.Server = map[string]string{}
|
||||
}
|
||||
if c.ProductionEnv.CLI == nil {
|
||||
c.ProductionEnv.CLI = map[string]string{}
|
||||
}
|
||||
for key, value := range defaults.Server {
|
||||
if _, ok := c.ProductionEnv.Server[key]; !ok {
|
||||
c.ProductionEnv.Server[key] = value
|
||||
}
|
||||
}
|
||||
for key, value := range defaults.CLI {
|
||||
if _, ok := c.ProductionEnv.CLI[key]; !ok {
|
||||
c.ProductionEnv.CLI[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
143
packages/browseros-agent/tools/alpha/config/config_test.go
Normal file
143
packages/browseros-agent/tools/alpha/config/config_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
cfg := Defaults(home)
|
||||
|
||||
if cfg.BrowserOSAppPath != "/Applications/BrowserOS.app/Contents/MacOS/BrowserOS" {
|
||||
t.Fatalf("unexpected browser path: %s", cfg.BrowserOSAppPath)
|
||||
}
|
||||
if cfg.SourceUserDataDir != filepath.Join(home, "Library/Application Support/BrowserOS") {
|
||||
t.Fatalf("unexpected source dir: %s", cfg.SourceUserDataDir)
|
||||
}
|
||||
if cfg.DevUserDataDir != filepath.Join(home, ".config/balpha/profile") {
|
||||
t.Fatalf("unexpected dev dir: %s", cfg.DevUserDataDir)
|
||||
}
|
||||
if cfg.DevProfileDir != "Default" {
|
||||
t.Fatalf("unexpected dev profile: %s", cfg.DevProfileDir)
|
||||
}
|
||||
if cfg.Ports.CDP != 9015 || cfg.Ports.Server != 9115 || cfg.Ports.Extension != 9315 {
|
||||
t.Fatalf("unexpected ports: %+v", cfg.Ports)
|
||||
}
|
||||
if cfg.ProductionEnv.Server["BROWSEROS_CONFIG_URL"] == "" {
|
||||
t.Fatalf("missing server production env defaults: %#v", cfg.ProductionEnv.Server)
|
||||
}
|
||||
if cfg.ProductionEnv.CLI["R2_BUCKET"] != "browseros" {
|
||||
t.Fatalf("missing cli production env defaults: %#v", cfg.ProductionEnv.CLI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
cfg := Config{
|
||||
RepoPath: "/repo",
|
||||
BrowserOSAppPath: "/Applications/BrowserOS.app/Contents/MacOS/BrowserOS",
|
||||
SourceUserDataDir: "/source",
|
||||
SourceProfileDir: "Profile 25",
|
||||
DevUserDataDir: "/dev",
|
||||
DevProfileDir: "Default",
|
||||
Ports: Ports{CDP: 9015, Server: 9115, Extension: 9315},
|
||||
ProductionEnv: ProductionEnv{
|
||||
Server: map[string]string{"NODE_ENV": "production"},
|
||||
CLI: map[string]string{"R2_BUCKET": "browseros"},
|
||||
},
|
||||
}
|
||||
|
||||
if err := Save(path, cfg); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
got, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.SourceProfileDir != cfg.SourceProfileDir {
|
||||
t.Fatalf("source profile mismatch: %q", got.SourceProfileDir)
|
||||
}
|
||||
if got.Ports.Server != 9115 {
|
||||
t.Fatalf("server port mismatch: %d", got.Ports.Server)
|
||||
}
|
||||
if got.ProductionEnv.CLI["R2_BUCKET"] != "browseros" {
|
||||
t.Fatalf("production env mismatch: %#v", got.ProductionEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpandTilde(t *testing.T) {
|
||||
got := ExpandTilde("~/x", "/Users/test")
|
||||
want := filepath.Join("/Users/test", "x")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRejectsSourceInsideDev(t *testing.T) {
|
||||
cfg := Config{
|
||||
RepoPath: t.TempDir(),
|
||||
BrowserOSAppPath: "/bin/sh",
|
||||
SourceUserDataDir: "/tmp/source",
|
||||
SourceProfileDir: "Default",
|
||||
DevUserDataDir: "/tmp/source/dev",
|
||||
DevProfileDir: "Default",
|
||||
Ports: Ports{CDP: 9015, Server: 9115, Extension: 9315},
|
||||
}
|
||||
if err := cfg.Validate(); err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigPathHonorsXDG(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
got, err := Path()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(dir, "balpha", "config.yaml")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPathDefault(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
got, err := Path()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join(home, ".config", "balpha", "config.yaml")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRepoShape(t *testing.T) {
|
||||
repo := t.TempDir()
|
||||
agentRoot := filepath.Join(repo, "packages/browseros-agent")
|
||||
if err := os.MkdirAll(agentRoot, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(agentRoot, "package.json"), []byte(`{"name":"browseros-monorepo"}`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := Config{
|
||||
RepoPath: repo,
|
||||
BrowserOSAppPath: "/bin/sh",
|
||||
SourceUserDataDir: "/tmp/source",
|
||||
SourceProfileDir: "Default",
|
||||
DevUserDataDir: "/tmp/dev",
|
||||
DevProfileDir: "Default",
|
||||
Ports: Ports{CDP: 9015, Server: 9115, Extension: 9315},
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
}
|
||||
17
packages/browseros-agent/tools/alpha/go.mod
Normal file
17
packages/browseros-agent/tools/alpha/go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module browseros-alpha
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
)
|
||||
22
packages/browseros-agent/tools/alpha/go.sum
Normal file
22
packages/browseros-agent/tools/alpha/go.sum
Normal file
@@ -0,0 +1,22 @@
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/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.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
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=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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=
|
||||
16
packages/browseros-agent/tools/alpha/internal/fspath/path.go
Normal file
16
packages/browseros-agent/tools/alpha/internal/fspath/path.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package fspath
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func IsSameOrChild(child string, parent string) bool {
|
||||
child = filepath.Clean(child)
|
||||
parent = filepath.Clean(parent)
|
||||
if child == parent {
|
||||
return true
|
||||
}
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
return err == nil && rel != "." && !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
7
packages/browseros-agent/tools/alpha/main.go
Normal file
7
packages/browseros-agent/tools/alpha/main.go
Normal file
@@ -0,0 +1,7 @@
|
||||
package main
|
||||
|
||||
import "browseros-alpha/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
18
packages/browseros-agent/tools/alpha/pipeline/build.go
Normal file
18
packages/browseros-agent/tools/alpha/pipeline/build.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package pipeline
|
||||
|
||||
func Build(agentRoot string, r Runner) error {
|
||||
if err := r.Run(agentRoot, "./tools/dev/setup.sh"); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.Run(agentRoot, "bun", "--cwd", "apps/agent", "--env-file=.env.development", "wxt", "build", "--mode", "development")
|
||||
}
|
||||
|
||||
type ExecRunner struct{}
|
||||
|
||||
func (ExecRunner) Run(dir string, args ...string) error {
|
||||
return runCommand(dir, args...)
|
||||
}
|
||||
|
||||
func (ExecRunner) OutputRun(dir string, args ...string) (string, error) {
|
||||
return outputCommand(dir, args...)
|
||||
}
|
||||
20
packages/browseros-agent/tools/alpha/pipeline/build_test.go
Normal file
20
packages/browseros-agent/tools/alpha/pipeline/build_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package pipeline
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildRunsExpectedCommands(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
r := &FakeRunner{}
|
||||
if err := Build(root, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{
|
||||
"./tools/dev/setup.sh",
|
||||
"bun --cwd apps/agent --env-file=.env.development wxt build --mode development",
|
||||
}
|
||||
for i := range want {
|
||||
if r.Commands[i] != want[i] {
|
||||
t.Fatalf("command %d got %q want %q", i, r.Commands[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
55
packages/browseros-agent/tools/alpha/pipeline/env.go
Normal file
55
packages/browseros-agent/tools/alpha/pipeline/env.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func WriteProductionEnvFiles(agentRoot string, cfg config.Config) error {
|
||||
cfg.FillProductionEnvDefaults()
|
||||
if err := writeEnvFile(filepath.Join(agentRoot, "apps/server/.env.production"), cfg.ProductionEnv.Server); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeEnvFile(filepath.Join(agentRoot, "apps/cli/.env.production"), cfg.ProductionEnv.CLI)
|
||||
}
|
||||
|
||||
func writeEnvFile(path string, values map[string]string) error {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
var out bytes.Buffer
|
||||
for _, key := range keys {
|
||||
line, err := formatEnvLine(key, values[key])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.WriteString(line)
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, out.Bytes(), 0600)
|
||||
}
|
||||
|
||||
func formatEnvLine(key string, value string) (string, error) {
|
||||
if key == "" || strings.ContainsAny(key, " \t\r\n=") {
|
||||
return "", fmt.Errorf("invalid env key %q", key)
|
||||
}
|
||||
if strings.ContainsAny(value, "\r\n") {
|
||||
return "", fmt.Errorf("env value for %s must not contain newlines", key)
|
||||
}
|
||||
if strings.ContainsAny(value, " \t#'\"=") {
|
||||
value = strconv.Quote(value)
|
||||
}
|
||||
return fmt.Sprintf("%s=%s", key, value), nil
|
||||
}
|
||||
74
packages/browseros-agent/tools/alpha/pipeline/env_test.go
Normal file
74
packages/browseros-agent/tools/alpha/pipeline/env_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func TestWriteProductionEnvFiles(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := config.Config{
|
||||
ProductionEnv: config.ProductionEnv{
|
||||
Server: map[string]string{
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info",
|
||||
},
|
||||
CLI: map[string]string{
|
||||
"R2_BUCKET": "browseros",
|
||||
"R2_UPLOAD_PREFIX": "cli",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := WriteProductionEnvFiles(root, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMode(t, filepath.Join(root, "apps/server/.env.production"), 0600)
|
||||
assertMode(t, filepath.Join(root, "apps/cli/.env.production"), 0600)
|
||||
assertContains(t, filepath.Join(root, "apps/server/.env.production"), "BROWSEROS_CONFIG_URL=https://llm.browseros.com/api/browseros-server/config\n")
|
||||
assertContains(t, filepath.Join(root, "apps/server/.env.production"), "LOG_LEVEL=info\n")
|
||||
assertContains(t, filepath.Join(root, "apps/server/.env.production"), "NODE_ENV=production\n")
|
||||
assertContains(t, filepath.Join(root, "apps/cli/.env.production"), "POSTHOG_API_KEY=\n")
|
||||
assertContains(t, filepath.Join(root, "apps/cli/.env.production"), "R2_BUCKET=browseros\n")
|
||||
assertContains(t, filepath.Join(root, "apps/cli/.env.production"), "R2_UPLOAD_PREFIX=cli\n")
|
||||
}
|
||||
|
||||
func TestWriteEnvFileQuotesUnsafeValues(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.production")
|
||||
if err := writeEnvFile(path, map[string]string{"TOKEN": "abc=123 with space"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertContains(t, path, "TOKEN=\"abc=123 with space\"\n")
|
||||
}
|
||||
|
||||
func TestWriteEnvFileRejectsNewlines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".env.production")
|
||||
if err := writeEnvFile(path, map[string]string{"TOKEN": "abc\n123"}); err == nil {
|
||||
t.Fatal("expected newline value error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, path string, want string) {
|
||||
t.Helper()
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(got), want) {
|
||||
t.Fatalf("%s missing %q in %q", path, want, string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func assertMode(t *testing.T, path string, want os.FileMode) {
|
||||
t.Helper()
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := info.Mode().Perm(); got != want {
|
||||
t.Fatalf("%s mode got %o want %o", path, got, want)
|
||||
}
|
||||
}
|
||||
24
packages/browseros-agent/tools/alpha/pipeline/exec.go
Normal file
24
packages/browseros-agent/tools/alpha/pipeline/exec.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func runCommand(dir string, args ...string) error {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Dir = dir
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func outputCommand(dir string, args ...string) (string, error) {
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Dir = dir
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
36
packages/browseros-agent/tools/alpha/pipeline/git.go
Normal file
36
packages/browseros-agent/tools/alpha/pipeline/git.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package pipeline
|
||||
|
||||
import "strings"
|
||||
|
||||
type Runner interface {
|
||||
Run(dir string, args ...string) error
|
||||
OutputRun(dir string, args ...string) (string, error)
|
||||
}
|
||||
|
||||
func Dirty(repoPath string, r Runner) (bool, error) {
|
||||
out, err := r.OutputRun(repoPath, "git", "status", "--porcelain")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return strings.TrimSpace(out) != "", nil
|
||||
}
|
||||
|
||||
func Pull(repoPath string, r Runner) error {
|
||||
return r.Run(repoPath, "git", "pull", "--ff-only")
|
||||
}
|
||||
|
||||
func Head(repoPath string, r Runner) (string, error) {
|
||||
out, err := r.OutputRun(repoPath, "git", "rev-parse", "--short", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(out), nil
|
||||
}
|
||||
|
||||
func Branch(repoPath string, r Runner) string {
|
||||
out, err := r.OutputRun(repoPath, "git", "branch", "--show-current")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(out)
|
||||
}
|
||||
53
packages/browseros-agent/tools/alpha/pipeline/git_test.go
Normal file
53
packages/browseros-agent/tools/alpha/pipeline/git_test.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package pipeline
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirtyStatus(t *testing.T) {
|
||||
r := &FakeRunner{Output: map[string]string{
|
||||
"git status --porcelain": " M file.go\n",
|
||||
}}
|
||||
dirty, err := Dirty("/repo", r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !dirty {
|
||||
t.Fatal("expected dirty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullRunsFastForwardOnly(t *testing.T) {
|
||||
r := &FakeRunner{}
|
||||
if err := Pull("/repo", r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := r.Commands[0]; got != "git pull --ff-only" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type FakeRunner struct {
|
||||
Commands []string
|
||||
Output map[string]string
|
||||
}
|
||||
|
||||
func (f *FakeRunner) Run(dir string, args ...string) error {
|
||||
f.Commands = append(f.Commands, join(args))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *FakeRunner) OutputRun(dir string, args ...string) (string, error) {
|
||||
cmd := join(args)
|
||||
f.Commands = append(f.Commands, cmd)
|
||||
return f.Output[cmd], nil
|
||||
}
|
||||
|
||||
func join(args []string) string {
|
||||
out := ""
|
||||
for i, arg := range args {
|
||||
if i > 0 {
|
||||
out += " "
|
||||
}
|
||||
out += arg
|
||||
}
|
||||
return out
|
||||
}
|
||||
46
packages/browseros-agent/tools/alpha/proc/log.go
Normal file
46
packages/browseros-agent/tools/alpha/proc/log.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
Color *color.Color
|
||||
}
|
||||
|
||||
var (
|
||||
TagBuild = Tag{"build", color.New(color.FgYellow)}
|
||||
TagAgent = Tag{"agent", color.New(color.FgMagenta)}
|
||||
TagServer = Tag{"server", color.New(color.FgCyan)}
|
||||
TagBrowser = Tag{"browser", color.New(color.FgBlue)}
|
||||
TagInfo = Tag{"info", color.New(color.FgGreen)}
|
||||
TagTest = Tag{"test", color.New(color.FgWhite)}
|
||||
|
||||
ErrorColor = color.New(color.FgRed)
|
||||
WarnColor = color.New(color.FgYellow)
|
||||
BoldColor = color.New(color.Bold)
|
||||
DimColor = color.New(color.Faint)
|
||||
)
|
||||
|
||||
func LogMsg(t Tag, msg string) {
|
||||
fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg)
|
||||
}
|
||||
|
||||
func LogMsgf(t Tag, format string, args ...any) {
|
||||
LogMsg(t, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func StreamLines(r interface{ Read([]byte) (int, error) }, t Tag) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line != "" {
|
||||
fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), line)
|
||||
}
|
||||
}
|
||||
}
|
||||
162
packages/browseros-agent/tools/alpha/proc/managed.go
Normal file
162
packages/browseros-agent/tools/alpha/proc/managed.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProcConfig struct {
|
||||
Tag Tag
|
||||
Dir string
|
||||
Env []string
|
||||
Restart bool
|
||||
Cmd []string
|
||||
BeforeStart func() error
|
||||
}
|
||||
|
||||
type ManagedProc struct {
|
||||
Cfg ProcConfig
|
||||
cancel context.CancelFunc
|
||||
mu sync.Mutex
|
||||
proc *os.Process
|
||||
exited chan struct{}
|
||||
}
|
||||
|
||||
func StartManaged(ctx context.Context, wg *sync.WaitGroup, cfg ProcConfig) *ManagedProc {
|
||||
procCtx, procCancel := context.WithCancel(ctx)
|
||||
mp := &ManagedProc{
|
||||
Cfg: cfg,
|
||||
cancel: procCancel,
|
||||
exited: make(chan struct{}),
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
mp.run(procCtx)
|
||||
}()
|
||||
|
||||
return mp
|
||||
}
|
||||
|
||||
func (mp *ManagedProc) run(ctx context.Context) {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if mp.Cfg.BeforeStart != nil {
|
||||
if err := mp.Cfg.BeforeStart(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
|
||||
|
||||
cmd := exec.Command(mp.Cfg.Cmd[0], mp.Cfg.Cmd[1:]...)
|
||||
cmd.Dir = mp.Cfg.Dir
|
||||
if mp.Cfg.Env != nil {
|
||||
cmd.Env = mp.Cfg.Env
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Error starting: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
exited := make(chan struct{})
|
||||
mp.mu.Lock()
|
||||
mp.proc = cmd.Process
|
||||
mp.exited = exited
|
||||
cancelled := ctx.Err() != nil
|
||||
mp.mu.Unlock()
|
||||
if cancelled {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
|
||||
}
|
||||
|
||||
var streamWg sync.WaitGroup
|
||||
streamWg.Add(2)
|
||||
go func() { defer streamWg.Done(); StreamLines(stdout, mp.Cfg.Tag) }()
|
||||
go func() { defer streamWg.Done(); StreamLines(stderr, mp.Cfg.Tag) }()
|
||||
|
||||
streamWg.Wait()
|
||||
_ = cmd.Wait()
|
||||
|
||||
mp.mu.Lock()
|
||||
mp.proc = nil
|
||||
close(mp.exited)
|
||||
mp.mu.Unlock()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
exitCode := cmd.ProcessState.ExitCode()
|
||||
if exitCode != 0 {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Process exited with code %d", exitCode))
|
||||
} else {
|
||||
LogMsg(mp.Cfg.Tag, "Process exited cleanly")
|
||||
}
|
||||
|
||||
if !mp.Cfg.Restart {
|
||||
return
|
||||
}
|
||||
|
||||
LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Restarting in 1s..."))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *ManagedProc) Stop() {
|
||||
mp.cancel()
|
||||
mp.mu.Lock()
|
||||
proc := mp.proc
|
||||
exited := mp.exited
|
||||
mp.mu.Unlock()
|
||||
|
||||
if proc != nil {
|
||||
_ = syscall.Kill(-proc.Pid, syscall.SIGTERM)
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = syscall.Kill(-proc.Pid, syscall.SIGKILL)
|
||||
select {
|
||||
case <-exited:
|
||||
case <-time.After(3 * time.Second):
|
||||
LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Process did not exit after SIGKILL, giving up"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *ManagedProc) ForceKill() {
|
||||
mp.mu.Lock()
|
||||
proc := mp.proc
|
||||
mp.mu.Unlock()
|
||||
|
||||
if proc != nil {
|
||||
_ = syscall.Kill(-proc.Pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
66
packages/browseros-agent/tools/alpha/proc/ports.go
Normal file
66
packages/browseros-agent/tools/alpha/proc/ports.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func ResolvePorts(start config.Ports) (config.Ports, bool, error) {
|
||||
used := map[int]bool{}
|
||||
cdp, err := resolvePort("CDP", start.CDP, used)
|
||||
if err != nil {
|
||||
return config.Ports{}, false, err
|
||||
}
|
||||
used[cdp] = true
|
||||
server, err := resolvePort("server", start.Server, used)
|
||||
if err != nil {
|
||||
return config.Ports{}, false, err
|
||||
}
|
||||
used[server] = true
|
||||
extension, err := resolvePort("extension", start.Extension, used)
|
||||
if err != nil {
|
||||
return config.Ports{}, false, err
|
||||
}
|
||||
resolved := config.Ports{CDP: cdp, Server: server, Extension: extension}
|
||||
return resolved, resolved != start, nil
|
||||
}
|
||||
|
||||
func resolvePort(name string, start int, used map[int]bool) (int, error) {
|
||||
if start <= 0 || start > 65535 {
|
||||
return 0, fmt.Errorf("invalid %s port: %d", name, start)
|
||||
}
|
||||
for port := start; port <= 65535; port++ {
|
||||
if used[port] {
|
||||
continue
|
||||
}
|
||||
if isPortAvailable(port) {
|
||||
return port, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("no available %s port at or above %d%s", name, start, pidSuffix(start))
|
||||
}
|
||||
|
||||
func isPortAvailable(port int) bool {
|
||||
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ln.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
func pidSuffix(port int) string {
|
||||
out, err := exec.Command("lsof", "-ti", fmt.Sprintf(":%d", port)).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
pids := strings.TrimSpace(string(out))
|
||||
if pids == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(" (pids: %s)", strings.ReplaceAll(pids, "\n", ","))
|
||||
}
|
||||
51
packages/browseros-agent/tools/alpha/proc/ports_test.go
Normal file
51
packages/browseros-agent/tools/alpha/proc/ports_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func TestResolvePortsIncrementsBusyPort(t *testing.T) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
got, changed, err := ResolvePorts(config.Ports{CDP: port, Server: 9115, Extension: 9315})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected changed ports")
|
||||
}
|
||||
if got.CDP == port {
|
||||
t.Fatalf("expected CDP port to move off busy port: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePortsAvoidsDuplicates(t *testing.T) {
|
||||
base := freePort(t)
|
||||
got, changed, err := ResolvePorts(config.Ports{CDP: base, Server: base, Extension: base})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatal("expected changed ports")
|
||||
}
|
||||
if got.CDP == got.Server || got.Server == got.Extension || got.CDP == got.Extension {
|
||||
t.Fatalf("ports must be distinct: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ln.Close()
|
||||
return ln.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
38
packages/browseros-agent/tools/alpha/proc/run.go
Normal file
38
packages/browseros-agent/tools/alpha/proc/run.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func RunBlocking(ctx context.Context, dir string, t Tag, args ...string) error {
|
||||
return runBlocking(ctx, dir, nil, t, args...)
|
||||
}
|
||||
|
||||
func RunBlockingWithEnv(ctx context.Context, dir string, env []string, t Tag, args ...string) error {
|
||||
return runBlocking(ctx, dir, env, t, args...)
|
||||
}
|
||||
|
||||
func runBlocking(ctx context.Context, dir string, env []string, t Tag, args ...string) error {
|
||||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||||
cmd.Dir = dir
|
||||
if env != nil {
|
||||
cmd.Env = env
|
||||
}
|
||||
|
||||
stdout, _ := cmd.StdoutPipe()
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); StreamLines(stdout, t) }()
|
||||
go func() { defer wg.Done(); StreamLines(stderr, t) }()
|
||||
wg.Wait()
|
||||
|
||||
return cmd.Wait()
|
||||
}
|
||||
212
packages/browseros-agent/tools/alpha/profile/import.go
Normal file
212
packages/browseros-agent/tools/alpha/profile/import.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"browseros-alpha/internal/fspath"
|
||||
)
|
||||
|
||||
type ImportConfig struct {
|
||||
SourceUserDataDir string
|
||||
SourceProfileDir string
|
||||
DevUserDataDir string
|
||||
DevProfileDir string
|
||||
}
|
||||
|
||||
var profileAllowlist = []string{
|
||||
"Extensions",
|
||||
"Local Extension Settings",
|
||||
"Login Data",
|
||||
"Login Data For Account",
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Bookmarks",
|
||||
"Preferences",
|
||||
"Web Data",
|
||||
"History",
|
||||
}
|
||||
|
||||
func Import(cfg ImportConfig) error {
|
||||
if cfg.SourceUserDataDir == "" || cfg.SourceProfileDir == "" || cfg.DevUserDataDir == "" || cfg.DevProfileDir == "" {
|
||||
return fmt.Errorf("source and dev profile paths are required")
|
||||
}
|
||||
if fspath.IsSameOrChild(cfg.DevUserDataDir, cfg.SourceUserDataDir) {
|
||||
return fmt.Errorf("dev user-data dir must not equal or live inside source user-data dir")
|
||||
}
|
||||
sourceProfile := filepath.Join(cfg.SourceUserDataDir, cfg.SourceProfileDir)
|
||||
if info, err := os.Stat(sourceProfile); err != nil || !info.IsDir() {
|
||||
return fmt.Errorf("source profile not found: %s", sourceProfile)
|
||||
}
|
||||
if err := os.RemoveAll(cfg.DevUserDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
devProfile := filepath.Join(cfg.DevUserDataDir, cfg.DevProfileDir)
|
||||
if err := os.MkdirAll(devProfile, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
localStatePath := filepath.Join(cfg.DevUserDataDir, "Local State")
|
||||
if err := copyIfExists(filepath.Join(cfg.SourceUserDataDir, "Local State"), localStatePath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := patchLocalState(localStatePath, cfg.SourceProfileDir, cfg.DevProfileDir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range profileAllowlist {
|
||||
src := filepath.Join(sourceProfile, name)
|
||||
dst := filepath.Join(devProfile, name)
|
||||
if err := copyIfExists(src, dst); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := patchPreferences(filepath.Join(devProfile, "Preferences")); err != nil {
|
||||
return err
|
||||
}
|
||||
return CleanupSingletons(cfg.DevUserDataDir)
|
||||
}
|
||||
|
||||
func CleanupSingletons(userDataDir string) error {
|
||||
entries, err := filepath.Glob(filepath.Join(userDataDir, "Singleton*"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if err := os.RemoveAll(entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func copyIfExists(src string, dst string) error {
|
||||
info, err := os.Stat(src)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return copyDir(src, dst)
|
||||
}
|
||||
return copyFile(src, dst, info.Mode())
|
||||
}
|
||||
|
||||
func copyDir(src string, dst string) error {
|
||||
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
return os.MkdirAll(target, info.Mode())
|
||||
}
|
||||
return copyFile(path, target, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
func copyFile(src string, dst string, mode os.FileMode) error {
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func patchPreferences(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var prefs map[string]any
|
||||
if err := json.Unmarshal(data, &prefs); err != nil {
|
||||
return nil
|
||||
}
|
||||
profile, ok := prefs["profile"].(map[string]any)
|
||||
if !ok {
|
||||
profile = map[string]any{}
|
||||
prefs["profile"] = profile
|
||||
}
|
||||
profile["exit_type"] = "Normal"
|
||||
profile["exited_cleanly"] = true
|
||||
out, err := json.Marshal(prefs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, out, 0644)
|
||||
}
|
||||
|
||||
func patchLocalState(path string, sourceProfileDir string, devProfileDir string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var state map[string]any
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil
|
||||
}
|
||||
profile := ensureObject(state, "profile")
|
||||
selected := selectedProfileInfo(profile, sourceProfileDir)
|
||||
profile["info_cache"] = map[string]any{devProfileDir: selected}
|
||||
profile["last_used"] = devProfileDir
|
||||
profile["last_active_profiles"] = []string{devProfileDir}
|
||||
profile["profiles_order"] = []string{devProfileDir}
|
||||
profile["show_picker_on_startup"] = false
|
||||
profile["picker_shown"] = true
|
||||
out, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, out, 0644)
|
||||
}
|
||||
|
||||
func ensureObject(parent map[string]any, key string) map[string]any {
|
||||
value, ok := parent[key].(map[string]any)
|
||||
if ok {
|
||||
return value
|
||||
}
|
||||
value = map[string]any{}
|
||||
parent[key] = value
|
||||
return value
|
||||
}
|
||||
|
||||
func selectedProfileInfo(profile map[string]any, sourceProfileDir string) map[string]any {
|
||||
infoCache, ok := profile["info_cache"].(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{"name": sourceProfileDir}
|
||||
}
|
||||
selected, ok := infoCache[sourceProfileDir].(map[string]any)
|
||||
if !ok {
|
||||
return map[string]any{"name": sourceProfileDir}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
163
packages/browseros-agent/tools/alpha/profile/import_test.go
Normal file
163
packages/browseros-agent/tools/alpha/profile/import_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportCopiesAllowlistAndLocalState(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
sourceUser := filepath.Join(root, "source")
|
||||
sourceProfile := filepath.Join(sourceUser, "Profile 25")
|
||||
devUser := filepath.Join(root, "dev")
|
||||
mustWrite(t, filepath.Join(sourceUser, "Local State"), `{
|
||||
"os_crypt": {"encrypted_key": "abc"},
|
||||
"profile": {
|
||||
"info_cache": {
|
||||
"Default": {"name": "Personal", "user_name": "me@example.com"},
|
||||
"Profile 25": {"name": "Sam", "user_name": "sam@example.test"}
|
||||
},
|
||||
"last_used": "Default",
|
||||
"last_active_profiles": ["Default", "Profile 25"],
|
||||
"profiles_order": ["Default", "Profile 25"],
|
||||
"show_picker_on_startup": true,
|
||||
"picker_shown": true
|
||||
}
|
||||
}`)
|
||||
mustWrite(t, filepath.Join(sourceProfile, "Bookmarks"), "bookmarks")
|
||||
mustWrite(t, filepath.Join(sourceProfile, "Preferences"), `{"profile":{"exit_type":"Crashed","exited_cleanly":false}}`)
|
||||
mustWrite(t, filepath.Join(sourceProfile, "Cache/junk"), "cache")
|
||||
mustWrite(t, filepath.Join(sourceProfile, "Extensions/ext/manifest.json"), "{}")
|
||||
|
||||
err := Import(ImportConfig{
|
||||
SourceUserDataDir: sourceUser,
|
||||
SourceProfileDir: "Profile 25",
|
||||
DevUserDataDir: devUser,
|
||||
DevProfileDir: "Default",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertImportedLocalState(t, filepath.Join(devUser, "Local State"))
|
||||
assertFile(t, filepath.Join(devUser, "Default", "Bookmarks"), "bookmarks")
|
||||
assertMissing(t, filepath.Join(devUser, "Default", "Cache"))
|
||||
assertFileExists(t, filepath.Join(devUser, "Default", "Extensions/ext/manifest.json"))
|
||||
prefs, err := os.ReadFile(filepath.Join(devUser, "Default", "Preferences"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(prefs) != `{"profile":{"exit_type":"Normal","exited_cleanly":true}}` {
|
||||
t.Fatalf("preferences not patched: %s", string(prefs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportRejectsDangerousDevDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
err := Import(ImportConfig{
|
||||
SourceUserDataDir: root,
|
||||
SourceProfileDir: "Default",
|
||||
DevUserDataDir: filepath.Join(root, "child"),
|
||||
DevProfileDir: "Default",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
|
||||
func assertImportedLocalState(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var state map[string]any
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
osCrypt := state["os_crypt"].(map[string]any)
|
||||
if osCrypt["encrypted_key"] != "abc" {
|
||||
t.Fatalf("os_crypt not preserved: %#v", osCrypt)
|
||||
}
|
||||
profile := state["profile"].(map[string]any)
|
||||
infoCache := profile["info_cache"].(map[string]any)
|
||||
if len(infoCache) != 1 {
|
||||
t.Fatalf("expected one dev profile in info_cache, got %#v", infoCache)
|
||||
}
|
||||
selected := infoCache["Default"].(map[string]any)
|
||||
if selected["name"] != "Sam" || selected["user_name"] != "sam@example.test" {
|
||||
t.Fatalf("selected profile metadata not remapped: %#v", selected)
|
||||
}
|
||||
if profile["last_used"] != "Default" {
|
||||
t.Fatalf("last_used mismatch: %#v", profile["last_used"])
|
||||
}
|
||||
assertStringList(t, profile["last_active_profiles"], []string{"Default"})
|
||||
assertStringList(t, profile["profiles_order"], []string{"Default"})
|
||||
if profile["show_picker_on_startup"] != false {
|
||||
t.Fatalf("profile picker not disabled: %#v", profile["show_picker_on_startup"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupSingletons(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "SingletonLock"), "lock")
|
||||
mustWrite(t, filepath.Join(dir, "SingletonCookie"), "cookie")
|
||||
if err := CleanupSingletons(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMissing(t, filepath.Join(dir, "SingletonLock"))
|
||||
assertMissing(t, filepath.Join(dir, "SingletonCookie"))
|
||||
}
|
||||
|
||||
func assertStringList(t *testing.T, got any, want []string) {
|
||||
t.Helper()
|
||||
values, ok := got.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("got %#v, want string list", got)
|
||||
}
|
||||
if len(values) != len(want) {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
for i, value := range values {
|
||||
if value != want[i] {
|
||||
t.Fatalf("got %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustWrite(t *testing.T, path string, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFile(t *testing.T, path string, want string) {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != want {
|
||||
t.Fatalf("%s got %q want %q", path, string(data), want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFileExists(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMissing(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected missing %s, err=%v", path, err)
|
||||
}
|
||||
}
|
||||
53
packages/browseros-agent/tools/alpha/profile/local_state.go
Normal file
53
packages/browseros-agent/tools/alpha/profile/local_state.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type BrowserProfile struct {
|
||||
Dir string
|
||||
Name string
|
||||
Email string
|
||||
}
|
||||
|
||||
type localState struct {
|
||||
Profile struct {
|
||||
InfoCache map[string]struct {
|
||||
Name string `json:"name"`
|
||||
UserName string `json:"user_name"`
|
||||
} `json:"info_cache"`
|
||||
} `json:"profile"`
|
||||
}
|
||||
|
||||
func ReadProfiles(userDataDir string) ([]BrowserProfile, error) {
|
||||
data, err := os.ReadFile(filepath.Join(userDataDir, "Local State"))
|
||||
if err != nil {
|
||||
return []BrowserProfile{{Dir: "Default", Name: "Default"}}, nil
|
||||
}
|
||||
var state localState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return []BrowserProfile{{Dir: "Default", Name: "Default"}}, nil
|
||||
}
|
||||
if len(state.Profile.InfoCache) == 0 {
|
||||
return []BrowserProfile{{Dir: "Default", Name: "Default"}}, nil
|
||||
}
|
||||
profiles := make([]BrowserProfile, 0, len(state.Profile.InfoCache))
|
||||
for dir, meta := range state.Profile.InfoCache {
|
||||
name := meta.Name
|
||||
if name == "" {
|
||||
name = dir
|
||||
}
|
||||
profiles = append(profiles, BrowserProfile{
|
||||
Dir: dir,
|
||||
Name: name,
|
||||
Email: meta.UserName,
|
||||
})
|
||||
}
|
||||
sort.Slice(profiles, func(i, j int) bool {
|
||||
return profiles[i].Dir < profiles[j].Dir
|
||||
})
|
||||
return profiles, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadProfilesFromLocalState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
localState := `{
|
||||
"profile": {
|
||||
"info_cache": {
|
||||
"Default": {"name": "Personal", "user_name": "me@example.com"},
|
||||
"Profile 25": {"name": "Work", "user_name": "work@example.com"}
|
||||
}
|
||||
}
|
||||
}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "Local State"), []byte(localState), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profiles, err := ReadProfiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(profiles) != 2 {
|
||||
t.Fatalf("expected 2 profiles, got %d", len(profiles))
|
||||
}
|
||||
if profiles[0].Dir != "Default" || profiles[1].Dir != "Profile 25" {
|
||||
t.Fatalf("profiles not sorted by dir: %+v", profiles)
|
||||
}
|
||||
if profiles[1].Name != "Work" || profiles[1].Email != "work@example.com" {
|
||||
t.Fatalf("profile metadata mismatch: %+v", profiles[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProfilesFallbackDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
profiles, err := ReadProfiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(profiles) != 1 || profiles[0].Dir != "Default" {
|
||||
t.Fatalf("fallback mismatch: %+v", profiles)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user