Compare commits

...

4 Commits

Author SHA1 Message Date
Nikhil Sonti
162e78ce78 fix(alpha): address log review feedback 2026-04-27 15:45:52 -07:00
Nikhil Sonti
2feac502af fix(alpha): write balpha process logs 2026-04-27 15:44:33 -07:00
Nikhil
599f8b6b9c fix: address balpha CLI dogfooding feedback (#831) 2026-04-27 15:43:22 -07:00
Nikhil
27834b1d31 fix: udpate readme (#829) 2026-04-27 15:27:16 -07:00
15 changed files with 615 additions and 40 deletions

View File

@@ -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)

View File

@@ -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.

View File

@@ -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),

View File

@@ -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",
} {

View 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
}

View 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)
}
}

View File

@@ -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
}

View File

@@ -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()
}

View 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)
}
}

View File

@@ -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": "",
},
}
}

View File

@@ -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) {

View File

@@ -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) {

View File

@@ -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, ""))
}

View 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)
}
}
}

View File

@@ -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