Files
BrowserOS/packages/browseros-agent/tools/dev/cmd/watch.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

231 lines
6.0 KiB
Go

package cmd
import (
"context"
"fmt"
"os"
"os/exec"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"browseros-dev/browser"
"browseros-dev/proc"
"github.com/spf13/cobra"
)
var watchCmd = &cobra.Command{
Use: "watch",
Short: "Start the dev environment with process supervision",
Long: "Starts the agent (WXT HMR or static), waits for CDP, then starts the server.",
RunE: runWatch,
}
var (
watchNew bool
watchManual bool
)
func init() {
watchCmd.Flags().BoolVar(&watchNew, "new", false, "Use random available ports in 9000-9999 and create a fresh user-data directory")
watchCmd.Flags().BoolVar(&watchManual, "manual", false, "Build agent statically instead of WXT HMR mode")
rootCmd.AddCommand(watchCmd)
}
func runWatch(cmd *cobra.Command, args []string) error {
root, err := proc.FindMonorepoRoot()
if err != nil {
return err
}
if err := ensureLimactlPresent(); err != nil {
return err
}
defaultPorts, err := resolveTargetPorts(root, "")
if err != nil {
return err
}
p := defaultPorts
var reservations *proc.PortReservations
userDataDir := "/tmp/browseros-dev"
mode := "watch"
if watchManual {
mode = "manual"
}
var runLock *proc.WatchRunLock
acquireRunLock := func(ports proc.Ports) error {
lock, stopped, err := proc.AcquireWatchRunLock(proc.WatchRunIdentity{
Mode: mode,
Profile: userDataDir,
Ports: ports,
}, 3*time.Second)
if err != nil {
return err
}
runLock = lock
if stopped {
proc.LogMsgf(proc.TagInfo, "Stopped existing dev watch for profile %s", userDataDir)
}
return nil
}
if watchNew {
proc.LogMsg(proc.TagInfo, "Selecting random available ports...")
p, reservations, err = proc.ResolveWatchPorts(true)
if err != nil {
return err
}
dir, err := os.MkdirTemp("", "browseros-dev-")
if err != nil {
return fmt.Errorf("creating temp dir: %w", err)
}
userDataDir = dir
proc.LogMsgf(proc.TagInfo, "Created fresh profile: %s", userDataDir)
if err := acquireRunLock(p); err != nil {
return err
}
} else {
if err := os.MkdirAll(userDataDir, 0o755); err != nil {
return fmt.Errorf("creating user-data dir: %w", err)
}
if err := acquireRunLock(p); err != nil {
return err
}
proc.LogMsg(proc.TagInfo, "Killing processes on preferred ports...")
if err := proc.KillPortsAndWait(defaultPorts, 3*time.Second); err != nil {
return err
}
proc.LogMsg(proc.TagInfo, "Ports cleared")
p, reservations, err = proc.ResolveWatchPorts(false)
if err != nil {
return err
}
if p != defaultPorts {
proc.LogMsgf(proc.TagInfo,
"Preferred ports unavailable, using fallback ports: CDP=%d Server=%d Extension=%d",
p.CDP, p.Server, p.Extension)
}
}
defer func() {
if err := runLock.Close(); err != nil {
proc.LogMsgf(proc.TagInfo, "Warning: closing run lock: %v", err)
}
}()
defer reservations.ReleaseAll()
if err := runDevSetup(cmd.Context(), root, setupModeIfNeeded); err != nil {
return err
}
fmt.Println()
proc.LogMsgf(proc.TagInfo, "Mode: %s", proc.BoldColor.Sprint(mode))
proc.LogMsgf(proc.TagInfo, "Ports: CDP=%d Server=%d Extension=%d", p.CDP, p.Server, p.Extension)
proc.LogMsgf(proc.TagInfo, "Profile: %s", userDataDir)
proc.LogMsg(proc.TagInfo, proc.DimColor.Sprint("Press Ctrl+C to stop, double Ctrl+C to force kill"))
fmt.Println()
env := proc.BuildEnv(p, "development")
env = append(env, fmt.Sprintf("BROWSEROS_USER_DATA_DIR=%s", userDataDir))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigCh := make(chan os.Signal, 2)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
var wg sync.WaitGroup
var procs []*proc.ManagedProc
agentDir := filepath.Join(root, "apps/agent")
if watchManual {
proc.LogMsg(proc.TagBuild, "Building agent (dev)...")
if err := proc.RunBlocking(ctx, agentDir, proc.TagBuild,
"bun", "--env-file=.env.development", "wxt", "build", "--mode", "development"); err != nil {
return fmt.Errorf("agent build failed: %w", err)
}
proc.LogMsg(proc.TagBuild, "agent built")
reservations.ReleaseCDP()
procs = append(procs, proc.StartManaged(ctx, &wg, proc.ProcConfig{
Tag: proc.TagBrowser,
Dir: root,
Restart: false,
Cmd: browser.BuildArgs(browser.ArgsConfig{
Root: root,
Ports: p,
UserDataDir: userDataDir,
LoadDevExtensions: true,
}),
}))
} else {
reservations.ReleaseCDP()
procs = append(procs, proc.StartManaged(ctx, &wg, proc.ProcConfig{
Tag: proc.TagAgent,
Dir: agentDir,
Env: env,
Restart: true,
Cmd: []string{"bun", "--env-file=.env.development", "wxt"},
}))
}
// Wait for CDP
proc.LogMsg(proc.TagServer, "Waiting for CDP...")
if browser.WaitForCDP(ctx, p.CDP, 60) {
proc.LogMsg(proc.TagServer, "CDP ready")
} else {
proc.LogMsg(proc.TagServer, proc.WarnColor.Sprint("CDP not available, starting server anyway"))
}
// Start server
reservations.ReleaseServer()
reservations.ReleaseExtension()
procs = append(procs, proc.StartManaged(ctx, &wg, proc.ProcConfig{
Tag: proc.TagServer,
Dir: filepath.Join(root, "apps/server"),
Env: env,
Restart: true,
Cmd: []string{"bun", "--watch", "--env-file=.env.development", "src/index.ts"},
BeforeStart: func() error {
return proc.KillPortAndWait(p.Server, 3*time.Second)
},
}))
<-sigCh
fmt.Println()
proc.LogMsg(proc.TagInfo, proc.WarnColor.Sprint("Shutting down (Ctrl+C again to force)..."))
cancel()
go func() {
<-sigCh
fmt.Println()
proc.LogMsg(proc.TagInfo, proc.ErrorColor.Sprint("Force killing all processes..."))
for _, p := range procs {
p.ForceKill()
}
os.Exit(1)
}()
for _, p := range procs {
p.Stop()
}
wg.Wait()
proc.LogMsg(proc.TagInfo, "All processes stopped")
return nil
}
func ensureLimactlPresent() error {
if _, err := exec.LookPath("limactl"); err != nil {
return fmt.Errorf("%s %s",
proc.ErrorColor.Sprint("Lima is not installed."),
proc.DimColor.Sprintf("Install with %s.", proc.BoldColor.Sprint("brew install lima")),
)
}
return nil
}