mirror of
https://github.com/browseros-ai/BrowserOS.git
synced 2026-05-13 23:53:25 +00:00
Compare commits
4 Commits
fix/readme
...
fix/dev-cl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
162e78ce78 | ||
|
|
2feac502af | ||
|
|
599f8b6b9c | ||
|
|
27834b1d31 |
@@ -2,11 +2,15 @@ BINARY := balpha
|
||||
SOURCES := $(shell find . -name '*.go') go.mod go.sum
|
||||
PREFIX ?= $(HOME)/bin
|
||||
|
||||
all: build
|
||||
|
||||
build: $(BINARY)
|
||||
|
||||
$(BINARY): $(SOURCES)
|
||||
@echo "[build] Compiling $(BINARY)..."
|
||||
@go build -o $(BINARY) .
|
||||
|
||||
.PHONY: install test clean
|
||||
.PHONY: all build install test clean
|
||||
|
||||
install: $(BINARY)
|
||||
@mkdir -p $(PREFIX)
|
||||
|
||||
@@ -1,50 +1,179 @@
|
||||
# balpha
|
||||
|
||||
Internal BrowserOS alpha dogfooding CLI.
|
||||
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/tools/alpha
|
||||
make install
|
||||
cd packages/browseros-agent
|
||||
bun run install:balpha
|
||||
```
|
||||
|
||||
## Configure
|
||||
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
|
||||
```
|
||||
|
||||
Config lives at `~/.config/balpha/config.yaml`.
|
||||
`init` asks for:
|
||||
|
||||
## Run
|
||||
- `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
|
||||
```
|
||||
|
||||
`start` uses the currently checked-out repo state. It does not pull.
|
||||
Each start runs the existing `tools/dev/setup.sh` setup flow and the WXT extension build. Bun/WXT caches keep repeated starts fast.
|
||||
Each start:
|
||||
|
||||
To refresh the repo explicitly:
|
||||
- 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.
|
||||
- Tees BrowserOS and server output to log files under the copied profile.
|
||||
|
||||
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`.
|
||||
|
||||
## Logs
|
||||
|
||||
`balpha start` writes process logs to:
|
||||
|
||||
```text
|
||||
~/.config/balpha/profile/logs
|
||||
```
|
||||
|
||||
The current files are:
|
||||
|
||||
- `chromium.log`: BrowserOS/Chromium stdout and stderr.
|
||||
- `server.log`: local Bun server stdout and stderr.
|
||||
|
||||
When either file is older than one day at startup, `balpha` rotates it to
|
||||
`<name>.old` before writing a fresh log.
|
||||
|
||||
To print the log directory and file paths:
|
||||
|
||||
```bash
|
||||
balpha logs
|
||||
```
|
||||
|
||||
## Update The Checkout
|
||||
|
||||
`balpha start` intentionally does not pull. To update the configured repo:
|
||||
|
||||
```bash
|
||||
balpha pull
|
||||
```
|
||||
|
||||
To re-copy the source BrowserOS profile:
|
||||
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
|
||||
```
|
||||
|
||||
## Notes
|
||||
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
|
||||
|
||||
- `balpha` launches the configured BrowserOS app with `--disable-browseros-server`.
|
||||
- The local Bun server runs from `packages/browseros-agent/apps/server`.
|
||||
- The dev extension is built from `packages/browseros-agent/apps/agent`.
|
||||
- The dev profile defaults to `~/.config/balpha/profile` and is separate from the real BrowserOS profile.
|
||||
- Generated `apps/server/.env.production` and `apps/cli/.env.production` files come from `production_env` in config.
|
||||
- Default ports are CDP `9015`, server `9115`, and extension `9315`; if a port is busy, `balpha start` auto-increments and saves the resolved ports.
|
||||
- Do not point `dev_user_data_dir` at the real BrowserOS profile.
|
||||
- `balpha` does not pass `--use-mock-keychain`; the default signed app path is required for copied login data to decrypt reliably.
|
||||
- `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.
|
||||
|
||||
@@ -24,6 +24,7 @@ func BuildArgs(cfg ArgsConfig) []string {
|
||||
"--show-component-extension-options",
|
||||
"--disable-browseros-server",
|
||||
"--disable-browseros-extensions",
|
||||
"--enable-logging=stderr",
|
||||
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),
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestBuildArgs(t *testing.T) {
|
||||
"--profile-directory=Default",
|
||||
"--disable-browseros-server",
|
||||
"--disable-browseros-extensions",
|
||||
"--enable-logging=stderr",
|
||||
"--load-extension=/repo/packages/browseros-agent/apps/agent/dist/chrome-mv3-dev",
|
||||
"chrome://newtab",
|
||||
} {
|
||||
|
||||
44
packages/browseros-agent/tools/alpha/cmd/logs.go
Normal file
44
packages/browseros-agent/tools/alpha/cmd/logs.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"browseros-alpha/config"
|
||||
"browseros-alpha/proc"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(logsCmd)
|
||||
}
|
||||
|
||||
var logsCmd = &cobra.Command{
|
||||
Use: "logs",
|
||||
Short: "Print balpha log files",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfigWithoutValidation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return printLogs(cmd.OutOrStdout(), cfg)
|
||||
},
|
||||
}
|
||||
|
||||
func printLogs(out io.Writer, cfg config.Config) error {
|
||||
logDir := cfg.LogDir()
|
||||
fmt.Fprintf(out, "Log directory: %s\n", logDir)
|
||||
files, err := proc.ListLogFiles(logDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(files) == 0 {
|
||||
fmt.Fprintln(out, "No log files found.")
|
||||
return nil
|
||||
}
|
||||
for _, file := range files {
|
||||
fmt.Fprintf(out, "%s (%d bytes, modified %s)\n", file.Path, file.Size, file.ModTime.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
packages/browseros-agent/tools/alpha/cmd/logs_test.go
Normal file
55
packages/browseros-agent/tools/alpha/cmd/logs_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"browseros-alpha/config"
|
||||
)
|
||||
|
||||
func TestPrintLogsShowsDirectoryAndFiles(t *testing.T) {
|
||||
devDir := t.TempDir()
|
||||
cfg := config.Config{DevUserDataDir: devDir}
|
||||
logDir := cfg.LogDir()
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"server.log", "chromium.log"} {
|
||||
if err := os.WriteFile(filepath.Join(logDir, name), []byte("log"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := printLogs(&out, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"Log directory: " + logDir,
|
||||
filepath.Join(logDir, "chromium.log"),
|
||||
filepath.Join(logDir, "server.log"),
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("missing %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintLogsHandlesMissingDirectory(t *testing.T) {
|
||||
cfg := config.Config{DevUserDataDir: t.TempDir()}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := printLogs(&out, cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "No log files found.") {
|
||||
t.Fatalf("unexpected output:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,17 @@ var refreshProfileCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
func loadConfig() (config.Config, error) {
|
||||
cfg, err := loadConfigWithoutValidation()
|
||||
if err != nil {
|
||||
return config.Config{}, err
|
||||
}
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return config.Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func loadConfigWithoutValidation() (config.Config, error) {
|
||||
path, err := config.Path()
|
||||
if err != nil {
|
||||
return config.Config{}, err
|
||||
@@ -43,8 +54,5 @@ func loadConfig() (config.Config, error) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,11 @@ import (
|
||||
var startRefreshProfile bool
|
||||
var startHeadless bool
|
||||
|
||||
const (
|
||||
serverLogName = "server.log"
|
||||
chromiumLogName = "chromium.log"
|
||||
)
|
||||
|
||||
func init() {
|
||||
startCmd.Flags().BoolVar(&startRefreshProfile, "refresh-profile", false, "Refresh copied BrowserOS profile before launch")
|
||||
startCmd.Flags().BoolVar(&startHeadless, "headless", false, "Run BrowserOS headless")
|
||||
@@ -84,12 +89,17 @@ func runEnvironment(cfg config.Config, agentRoot string) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := os.MkdirAll(cfg.LogDir(), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
var managed []*proc.ManagedProc
|
||||
managed = append(managed, proc.StartManaged(ctx, &wg, proc.ProcConfig{
|
||||
Tag: proc.TagBrowser,
|
||||
Dir: agentRoot,
|
||||
Restart: false,
|
||||
LogPath: cfg.LogPath(chromiumLogName),
|
||||
Cmd: browser.BuildArgs(browser.ArgsConfig{
|
||||
Binary: cfg.BrowserOSAppPath,
|
||||
AgentRoot: agentRoot,
|
||||
@@ -119,7 +129,8 @@ func runEnvironment(cfg config.Config, agentRoot string) error {
|
||||
Dir: serverDir,
|
||||
Env: env,
|
||||
Restart: true,
|
||||
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
|
||||
LogPath: cfg.LogPath(serverLogName),
|
||||
Cmd: serverCommand(),
|
||||
}))
|
||||
printSummary(cfg, agentRoot)
|
||||
|
||||
@@ -157,6 +168,10 @@ func runEnvironment(cfg config.Config, agentRoot string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func serverCommand() []string {
|
||||
return []string{"bun", "--env-file=.env.development", "src/index.ts"}
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
@@ -168,6 +183,7 @@ func printSummary(cfg config.Config, agentRoot string) {
|
||||
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, "Logs: %s", cfg.LogDir())
|
||||
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", cfg.Ports.CDP, cfg.Ports.Server, cfg.Ports.Extension)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
14
packages/browseros-agent/tools/alpha/cmd/start_test.go
Normal file
14
packages/browseros-agent/tools/alpha/cmd/start_test.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestServerCommandDoesNotWatchFiles(t *testing.T) {
|
||||
got := serverCommand()
|
||||
want := []string{"bun", "--env-file=.env.development", "src/index.ts"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("server command got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,8 @@ type packageJSON struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
const LogDirName = "logs"
|
||||
|
||||
func Path() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -127,6 +129,14 @@ func (c Config) DevProfilePath() string {
|
||||
return filepath.Join(c.DevUserDataDir, c.DevProfileDir)
|
||||
}
|
||||
|
||||
func (c Config) LogDir() string {
|
||||
return filepath.Join(c.DevUserDataDir, LogDirName)
|
||||
}
|
||||
|
||||
func (c Config) LogPath(name string) string {
|
||||
return filepath.Join(c.LogDir(), name)
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.RepoPath == "" {
|
||||
return fmt.Errorf("repo_path is required")
|
||||
@@ -195,7 +205,7 @@ func DefaultProductionEnv() ProductionEnv {
|
||||
"R2_DOWNLOAD_PREFIX": "artifacts/vendor",
|
||||
"R2_UPLOAD_PREFIX": "artifacts/server",
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info",
|
||||
"LOG_LEVEL": "debug",
|
||||
},
|
||||
CLI: map[string]string{
|
||||
"POSTHOG_API_KEY": "",
|
||||
@@ -203,7 +213,7 @@ func DefaultProductionEnv() ProductionEnv {
|
||||
"R2_ACCESS_KEY_ID": "",
|
||||
"R2_SECRET_ACCESS_KEY": "",
|
||||
"R2_BUCKET": "browseros",
|
||||
"R2_UPLOAD_PREFIX": "cli",
|
||||
"R2_UPLOAD_PREFIX": "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.DevUserDataDir != filepath.Join(home, ".config/balpha/profile") {
|
||||
t.Fatalf("unexpected dev dir: %s", cfg.DevUserDataDir)
|
||||
}
|
||||
if cfg.LogDir() != filepath.Join(home, ".config/balpha/profile/logs") {
|
||||
t.Fatalf("unexpected log dir: %s", cfg.LogDir())
|
||||
}
|
||||
if cfg.DevProfileDir != "Default" {
|
||||
t.Fatalf("unexpected dev profile: %s", cfg.DevProfileDir)
|
||||
}
|
||||
@@ -29,9 +32,24 @@ func TestDefaults(t *testing.T) {
|
||||
if cfg.ProductionEnv.Server["BROWSEROS_CONFIG_URL"] == "" {
|
||||
t.Fatalf("missing server production env defaults: %#v", cfg.ProductionEnv.Server)
|
||||
}
|
||||
if cfg.ProductionEnv.Server["LOG_LEVEL"] != "debug" {
|
||||
t.Fatalf("server log level got %q want debug", cfg.ProductionEnv.Server["LOG_LEVEL"])
|
||||
}
|
||||
if cfg.ProductionEnv.CLI["R2_BUCKET"] != "browseros" {
|
||||
t.Fatalf("missing cli production env defaults: %#v", cfg.ProductionEnv.CLI)
|
||||
}
|
||||
if cfg.ProductionEnv.CLI["R2_UPLOAD_PREFIX"] != "" {
|
||||
t.Fatalf("cli upload prefix got %q want empty", cfg.ProductionEnv.CLI["R2_UPLOAD_PREFIX"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogPathUsesProfileLogDir(t *testing.T) {
|
||||
cfg := Config{DevUserDataDir: "/tmp/balpha-profile"}
|
||||
got := cfg.LogPath("server.log")
|
||||
want := filepath.Join("/tmp/balpha-profile", "logs", "server.log")
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveLoadRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -15,11 +15,11 @@ func TestWriteProductionEnvFiles(t *testing.T) {
|
||||
ProductionEnv: config.ProductionEnv{
|
||||
Server: map[string]string{
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info",
|
||||
"LOG_LEVEL": "debug",
|
||||
},
|
||||
CLI: map[string]string{
|
||||
"R2_BUCKET": "browseros",
|
||||
"R2_UPLOAD_PREFIX": "cli",
|
||||
"R2_UPLOAD_PREFIX": "",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -29,11 +29,11 @@ func TestWriteProductionEnvFiles(t *testing.T) {
|
||||
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"), "LOG_LEVEL=debug\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")
|
||||
assertContains(t, filepath.Join(root, "apps/cli/.env.production"), "R2_UPLOAD_PREFIX=\n")
|
||||
}
|
||||
|
||||
func TestWriteEnvFileQuotesUnsafeValues(t *testing.T) {
|
||||
|
||||
@@ -3,15 +3,32 @@ package proc
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
const LogMaxAge = 24 * time.Hour
|
||||
|
||||
type Tag struct {
|
||||
Name string
|
||||
Color *color.Color
|
||||
}
|
||||
|
||||
type LogFile struct {
|
||||
Name string
|
||||
Path string
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
var (
|
||||
TagBuild = Tag{"build", color.New(color.FgYellow)}
|
||||
TagAgent = Tag{"agent", color.New(color.FgMagenta)}
|
||||
@@ -24,23 +41,115 @@ var (
|
||||
WarnColor = color.New(color.FgYellow)
|
||||
BoldColor = color.New(color.Bold)
|
||||
DimColor = color.New(color.Faint)
|
||||
|
||||
ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
)
|
||||
|
||||
func LogMsg(t Tag, msg string) {
|
||||
fmt.Printf("%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg)
|
||||
logMsg(t, msg, os.Stdout, nil, nil)
|
||||
}
|
||||
|
||||
func LogMsgf(t Tag, format string, args ...any) {
|
||||
LogMsg(t, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func StreamLines(r interface{ Read([]byte) (int, error) }, t Tag) {
|
||||
func LogMsgTee(t Tag, msg string, file io.Writer, fileMu *sync.Mutex) {
|
||||
logMsg(t, msg, os.Stdout, file, fileMu)
|
||||
}
|
||||
|
||||
func StreamLines(r io.Reader, t Tag) {
|
||||
streamLines(r, t, os.Stdout, nil, nil)
|
||||
}
|
||||
|
||||
func OpenLogFile(logDir string, name string, now time.Time) (*os.File, string, error) {
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
path := filepath.Join(logDir, name)
|
||||
if err := rotateLogIfNeeded(path, now); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return file, path, nil
|
||||
}
|
||||
|
||||
func ListLogFiles(logDir string) ([]LogFile, error) {
|
||||
entries, err := os.ReadDir(logDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files := []LogFile{}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasSuffix(entry.Name(), ".log") && !strings.HasSuffix(entry.Name(), ".log.old") {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
continue
|
||||
}
|
||||
files = append(files, LogFile{
|
||||
Name: entry.Name(),
|
||||
Path: filepath.Join(logDir, entry.Name()),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
return files[i].Name < files[j].Name
|
||||
})
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func rotateLogIfNeeded(logPath string, now time.Time) error {
|
||||
info, err := os.Stat(logPath)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if now.Sub(info.ModTime()) <= LogMaxAge {
|
||||
return nil
|
||||
}
|
||||
backupPath := logPath + ".old"
|
||||
if err := os.Remove(backupPath); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return os.Rename(logPath, backupPath)
|
||||
}
|
||||
|
||||
func streamLines(r io.Reader, t Tag, terminal io.Writer, file io.Writer, fileMu *sync.Mutex) {
|
||||
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)
|
||||
logMsg(t, line, terminal, file, fileMu)
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
logMsg(t, fmt.Sprintf("log stream error: %v", err), terminal, file, fileMu)
|
||||
}
|
||||
}
|
||||
|
||||
func logMsg(t Tag, msg string, terminal io.Writer, file io.Writer, fileMu *sync.Mutex) {
|
||||
if fileMu != nil {
|
||||
fileMu.Lock()
|
||||
defer fileMu.Unlock()
|
||||
}
|
||||
fmt.Fprintf(terminal, "%s %s\n", t.Color.Sprintf("[%s]", t.Name), msg)
|
||||
if file == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(file, "[%s] %s\n", t.Name, ansiPattern.ReplaceAllString(msg, ""))
|
||||
}
|
||||
|
||||
147
packages/browseros-agent/tools/alpha/proc/log_test.go
Normal file
147
packages/browseros-agent/tools/alpha/proc/log_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestOpenLogFileRotatesFileOlderThanOneDay(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "server.log")
|
||||
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
oldTime := now.Add(-25 * time.Hour)
|
||||
if err := os.Chtimes(path, oldTime, oldTime); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file, gotPath, err := OpenLogFile(dir, "server.log", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != path {
|
||||
t.Fatalf("got %q want %q", gotPath, path)
|
||||
}
|
||||
if _, err := file.WriteString("new\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rotated, err := os.ReadFile(path + ".old")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(rotated) != "old\n" {
|
||||
t.Fatalf("rotated content = %q", rotated)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(current) != "new\n" {
|
||||
t.Fatalf("current content = %q", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenLogFileAppendsFreshFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "chromium.log")
|
||||
if err := os.WriteFile(path, []byte("old\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 4, 27, 12, 0, 0, 0, time.UTC)
|
||||
fresh := now.Add(-time.Hour)
|
||||
if err := os.Chtimes(path, fresh, fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
file, _, err := OpenLogFile(dir, "chromium.log", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := file.WriteString("new\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(path + ".old"); !os.IsNotExist(err) {
|
||||
t.Fatalf("unexpected rotated file, stat err=%v", err)
|
||||
}
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(current) != "old\nnew\n" {
|
||||
t.Fatalf("current content = %q", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListLogFilesReturnsRegularFilesSortedByName(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for _, name := range []string{"server.log", "chromium.log.old", "chromium.log", "server.log.backup"} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(dir, "nested.log"), 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
files, err := ListLogFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := []string{}
|
||||
for _, file := range files {
|
||||
got = append(got, file.Name)
|
||||
}
|
||||
want := []string{"chromium.log", "chromium.log.old", "server.log"}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("got %#v want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamLinesWritesTerminalAndFile(t *testing.T) {
|
||||
var terminal bytes.Buffer
|
||||
var file bytes.Buffer
|
||||
var mu sync.Mutex
|
||||
|
||||
streamLines(strings.NewReader("first\nsecond\n"), TagServer, &terminal, &file, &mu)
|
||||
|
||||
terminalOutput := terminal.String()
|
||||
if !strings.Contains(terminalOutput, "[server] first") || !strings.Contains(terminalOutput, "[server] second") {
|
||||
t.Fatalf("unexpected terminal output: %q", terminalOutput)
|
||||
}
|
||||
fileOutput := file.String()
|
||||
if fileOutput != "[server] first\n[server] second\n" {
|
||||
t.Fatalf("unexpected file output: %q", fileOutput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamLinesLogsScannerErrors(t *testing.T) {
|
||||
var terminal bytes.Buffer
|
||||
var file bytes.Buffer
|
||||
var mu sync.Mutex
|
||||
longLine := strings.Repeat("x", 1024*1024+1)
|
||||
|
||||
streamLines(strings.NewReader(longLine), TagBrowser, &terminal, &file, &mu)
|
||||
|
||||
for name, got := range map[string]string{
|
||||
"terminal": terminal.String(),
|
||||
"file": file.String(),
|
||||
} {
|
||||
if !strings.Contains(got, "log stream error: bufio.Scanner: token too long") {
|
||||
t.Fatalf("%s output missing scanner error: %q", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@ package proc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -17,6 +19,7 @@ type ProcConfig struct {
|
||||
Restart bool
|
||||
Cmd []string
|
||||
BeforeStart func() error
|
||||
LogPath string
|
||||
}
|
||||
|
||||
type ManagedProc struct {
|
||||
@@ -45,6 +48,22 @@ func StartManaged(ctx context.Context, wg *sync.WaitGroup, cfg ProcConfig) *Mana
|
||||
}
|
||||
|
||||
func (mp *ManagedProc) run(ctx context.Context) {
|
||||
var logFile *os.File
|
||||
var logMu sync.Mutex
|
||||
if mp.Cfg.LogPath != "" {
|
||||
file, path, err := OpenLogFile(filepath.Dir(mp.Cfg.LogPath), filepath.Base(mp.Cfg.LogPath), time.Now())
|
||||
if err != nil {
|
||||
LogMsg(mp.Cfg.Tag, WarnColor.Sprintf("File logging disabled: %v", err))
|
||||
} else {
|
||||
logFile = file
|
||||
defer logFile.Close()
|
||||
LogMsgTee(mp.Cfg.Tag, "Writing log file: "+path, logFile, &logMu)
|
||||
}
|
||||
}
|
||||
log := func(msg string) {
|
||||
LogMsgTee(mp.Cfg.Tag, msg, logFile, &logMu)
|
||||
}
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -52,7 +71,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
|
||||
if mp.Cfg.BeforeStart != nil {
|
||||
if err := mp.Cfg.BeforeStart(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Pre-start failed: %v", err))
|
||||
log(ErrorColor.Sprintf("Pre-start failed: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -61,7 +80,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
LogMsgf(mp.Cfg.Tag, "Starting: %s", DimColor.Sprint(strings.Join(mp.Cfg.Cmd, " ")))
|
||||
log(fmt.Sprintf("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
|
||||
@@ -74,7 +93,7 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
stderr, _ := cmd.StderrPipe()
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Error starting: %v", err))
|
||||
log(ErrorColor.Sprintf("Error starting: %v", err))
|
||||
if !mp.Cfg.Restart || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
@@ -94,8 +113,8 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
|
||||
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) }()
|
||||
go func() { defer streamWg.Done(); streamLines(stdout, mp.Cfg.Tag, os.Stdout, logFile, &logMu) }()
|
||||
go func() { defer streamWg.Done(); streamLines(stderr, mp.Cfg.Tag, os.Stdout, logFile, &logMu) }()
|
||||
|
||||
streamWg.Wait()
|
||||
_ = cmd.Wait()
|
||||
@@ -111,16 +130,16 @@ func (mp *ManagedProc) run(ctx context.Context) {
|
||||
|
||||
exitCode := cmd.ProcessState.ExitCode()
|
||||
if exitCode != 0 {
|
||||
LogMsg(mp.Cfg.Tag, ErrorColor.Sprintf("Process exited with code %d", exitCode))
|
||||
log(ErrorColor.Sprintf("Process exited with code %d", exitCode))
|
||||
} else {
|
||||
LogMsg(mp.Cfg.Tag, "Process exited cleanly")
|
||||
log("Process exited cleanly")
|
||||
}
|
||||
|
||||
if !mp.Cfg.Restart {
|
||||
return
|
||||
}
|
||||
|
||||
LogMsg(mp.Cfg.Tag, WarnColor.Sprint("Restarting in 1s..."))
|
||||
log(WarnColor.Sprint("Restarting in 1s..."))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user