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 }