Files
BrowserOS/packages/browseros-agent/tools/dev/cmd/target.go
Nikhil ffc0f09c86 feat(dev): add target-aware reset cleanup (#893)
* feat(dev): add target-aware reset cleanup

* fix(dev): address cleanup reset review comments
2026-04-30 13:34:52 -07:00

366 lines
9.6 KiB
Go

package cmd
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"browseros-dev/proc"
"gopkg.in/yaml.v3"
)
const (
targetDev = "dev"
targetDogfood = "dogfood"
targetProd = "prod"
devDirName = ".browseros-dev"
prodDirName = ".browseros"
)
type resetTargetOptions struct {
Target string
BrowserOSDir string
Ports string
BrowserUserDataDir string
}
type resetTarget struct {
Name string
Title string
BrowserOSDir string
LimaHome string
Ports *proc.Ports
BrowserUserDataDirs []string
TempPrefixes []string
WatchRunStateDir string
DeleteRootLabel string
DeleteRootBody string
Dogfood *dogfoodRuntimeTarget
}
type dogfoodRuntimeTarget struct {
ConfigDir string
LockPath string
StatePath string
SocketPath string
}
type dogfoodConfigFile struct {
BrowserOSDir string `yaml:"browseros_dir"`
DevUserDataDir string `yaml:"dev_user_data_dir"`
Ports struct {
CDP int `yaml:"cdp"`
Server int `yaml:"server"`
Extension int `yaml:"extension"`
} `yaml:"ports"`
}
func resolveResetTarget(root string, opts resetTargetOptions) (resetTarget, error) {
target := strings.TrimSpace(opts.Target)
if target == "" {
target = targetDev
}
switch target {
case targetDev:
return resolveDevTarget(root, opts)
case targetDogfood:
return resolveDogfoodTarget(opts)
case targetProd:
return resolveProdTarget(opts)
default:
return resetTarget{}, fmt.Errorf("unsupported reset target %q", target)
}
}
func resolveDevTarget(root string, opts resetTargetOptions) (resetTarget, error) {
browserosDir, err := resolveBrowserOSDir(opts.BrowserOSDir, devDirName)
if err != nil {
return resetTarget{}, err
}
ports, err := resolveTargetPorts(root, opts.Ports)
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetDev,
Title: "BrowserOS dev reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
Ports: &ports,
BrowserUserDataDirs: []string{"/tmp/browseros-dev"},
TempPrefixes: []string{"browseros-test-", "browseros-dev-"},
WatchRunStateDir: filepath.Join(browserosDir, "runs"),
DeleteRootLabel: "Delete dev profile?",
DeleteRootBody: "It removes BrowserOS dev data plus VM/OpenClaw state.",
}, nil
}
func resolveDogfoodTarget(opts resetTargetOptions) (resetTarget, error) {
cfgDir, err := dogfoodConfigDir()
if err != nil {
return resetTarget{}, err
}
cfg, err := loadDogfoodConfig(filepath.Join(cfgDir, "config.yaml"))
if err != nil {
return resetTarget{}, err
}
applyDogfoodDefaults(&cfg, cfgDir)
browserosDir := firstNonEmpty(opts.BrowserOSDir, cfg.BrowserOSDir)
if browserosDir == "" {
return resetTarget{}, fmt.Errorf("dogfood browseros_dir is empty")
}
browserosDir, err = filepath.Abs(expandTilde(browserosDir))
if err != nil {
return resetTarget{}, err
}
ports, err := parsePorts(firstNonEmpty(opts.Ports, formatPorts(proc.Ports{
CDP: cfg.Ports.CDP,
Server: cfg.Ports.Server,
Extension: cfg.Ports.Extension,
})))
if err != nil {
return resetTarget{}, err
}
browserUserDataDir := firstNonEmpty(opts.BrowserUserDataDir, cfg.DevUserDataDir)
if browserUserDataDir == "" {
return resetTarget{}, fmt.Errorf("dogfood dev_user_data_dir is empty")
}
browserUserDataDir, err = filepath.Abs(expandTilde(browserUserDataDir))
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetDogfood,
Title: "BrowserOS dogfood reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
Ports: &ports,
BrowserUserDataDirs: []string{browserUserDataDir},
DeleteRootLabel: "Delete dogfood BrowserOS state?",
DeleteRootBody: "It removes dogfood-local BrowserOS server data plus VM/OpenClaw state. It does not touch your source BrowserOS browser profile.",
Dogfood: &dogfoodRuntimeTarget{
ConfigDir: cfgDir,
LockPath: filepath.Join(cfgDir, "run.lock"),
StatePath: filepath.Join(cfgDir, "state.json"),
SocketPath: filepath.Join(cfgDir, "daemon.sock"),
},
}, nil
}
func applyDogfoodDefaults(cfg *dogfoodConfigFile, cfgDir string) {
if cfg.BrowserOSDir == "" {
if home, err := os.UserHomeDir(); err == nil {
cfg.BrowserOSDir = filepath.Join(home, ".browseros-dogfood")
}
}
if cfg.DevUserDataDir == "" {
cfg.DevUserDataDir = filepath.Join(cfgDir, "profile")
}
if cfg.Ports.CDP == 0 {
cfg.Ports.CDP = 9015
}
if cfg.Ports.Server == 0 {
cfg.Ports.Server = 9115
}
if cfg.Ports.Extension == 0 {
cfg.Ports.Extension = 9315
}
}
func resolveProdTarget(opts resetTargetOptions) (resetTarget, error) {
browserosDir, err := resolveBrowserOSDir(opts.BrowserOSDir, prodDirName)
if err != nil {
return resetTarget{}, err
}
return resetTarget{
Name: targetProd,
Title: "BrowserOS prod reset",
BrowserOSDir: browserosDir,
LimaHome: filepath.Join(browserosDir, "lima"),
DeleteRootLabel: "Delete prod BrowserOS state?",
DeleteRootBody: "It removes ~/.browseros server data plus VM/OpenClaw state. It does not delete your BrowserOS browser profile.",
}, nil
}
func resolveBrowserOSDir(override string, dirName string) (string, error) {
if strings.TrimSpace(override) != "" {
return filepath.Abs(expandTilde(strings.TrimSpace(override)))
}
if dirName == devDirName {
if env := strings.TrimSpace(os.Getenv("BROWSEROS_DIR")); env != "" {
return filepath.Abs(expandTilde(env))
}
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, dirName), nil
}
func resolveTargetPorts(root string, explicit string) (proc.Ports, error) {
if strings.TrimSpace(explicit) != "" {
return parsePorts(explicit)
}
for _, path := range []string{
filepath.Join(root, "apps/server/.env.development"),
filepath.Join(root, "apps/server/.env.example"),
} {
ports, ok, err := readPortsFromEnvFile(path)
if err != nil {
return proc.Ports{}, err
}
if ok {
return ports, nil
}
}
return proc.DefaultLocalPorts(), nil
}
func readPortsFromEnvFile(path string) (proc.Ports, bool, error) {
file, err := os.Open(path)
if os.IsNotExist(err) {
return proc.Ports{}, false, nil
}
if err != nil {
return proc.Ports{}, false, err
}
defer file.Close()
values := map[string]int{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
key, value, ok := parseEnvLine(scanner.Text())
if !ok {
continue
}
switch key {
case "BROWSEROS_CDP_PORT", "BROWSEROS_SERVER_PORT", "BROWSEROS_EXTENSION_PORT":
port, err := strconv.Atoi(value)
if err != nil {
return proc.Ports{}, false, fmt.Errorf("parse %s in %s: %w", key, path, err)
}
values[key] = port
}
}
if err := scanner.Err(); err != nil {
return proc.Ports{}, false, err
}
if len(values) != 3 {
return proc.Ports{}, false, nil
}
return proc.Ports{
CDP: values["BROWSEROS_CDP_PORT"],
Server: values["BROWSEROS_SERVER_PORT"],
Extension: values["BROWSEROS_EXTENSION_PORT"],
}, true, nil
}
func parseEnvLine(line string) (string, string, bool) {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
return "", "", false
}
key, value, ok := strings.Cut(line, "=")
if !ok {
return "", "", false
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(stripInlineComment(value))
value = strings.Trim(value, `"'`)
return key, value, key != "" && value != ""
}
func stripInlineComment(value string) string {
quote := byte(0)
for index := 0; index < len(value); index++ {
switch value[index] {
case '\'', '"':
if quote == 0 {
quote = value[index]
} else if quote == value[index] {
quote = 0
}
case '#':
if quote == 0 {
return value[:index]
}
}
}
return value
}
func parsePorts(value string) (proc.Ports, error) {
parts := strings.Split(value, ",")
if len(parts) != 3 {
return proc.Ports{}, fmt.Errorf("ports must be cdp,server,extension")
}
parsed := [3]int{}
for i, part := range parts {
port, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return proc.Ports{}, fmt.Errorf("parse port %q: %w", part, err)
}
if port <= 0 || port > 65535 {
return proc.Ports{}, fmt.Errorf("port %d out of range", port)
}
parsed[i] = port
}
return proc.Ports{CDP: parsed[0], Server: parsed[1], Extension: parsed[2]}, nil
}
func formatPorts(ports proc.Ports) string {
return fmt.Sprintf("%d,%d,%d", ports.CDP, ports.Server, ports.Extension)
}
func dogfoodConfigDir() (string, error) {
if xdg := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME")); xdg != "" {
return filepath.Join(expandTilde(xdg), "browseros-dogfood"), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".config", "browseros-dogfood"), nil
}
func loadDogfoodConfig(path string) (dogfoodConfigFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return dogfoodConfigFile{}, fmt.Errorf("read dogfood config at %s: %w", path, err)
}
var cfg dogfoodConfigFile
if err := yaml.Unmarshal(data, &cfg); err != nil {
return dogfoodConfigFile{}, fmt.Errorf("parse dogfood config: %w", err)
}
return cfg, nil
}
func expandTilde(path string) string {
if path == "~" {
if home, err := os.UserHomeDir(); err == nil {
return home
}
}
if strings.HasPrefix(path, "~/") {
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, path[2:])
}
}
return path
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}