package cmd import ( "bufio" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "browseros-dev/proc" "github.com/spf13/cobra" ) const ( limaVMName = "browseros-vm" openClawImage = "ghcr.io/openclaw/openclaw:2026.4.12" openClawContainerName = "browseros-openclaw-openclaw-gateway-1" openClawSetupContainer = openClawContainerName + "-setup" ) var resetCmd = &cobra.Command{ Use: "reset", Short: "Guide destructive BrowserOS profile and VM resets", Long: "Walks through safe cleanup, VM shutdown/deletion, OpenClaw container/image removal, and target BrowserOS state reset.", RunE: runReset, } type resetPrompt struct { Title string Body string Action string } type limaListEntry struct { Name string `json:"name"` Status string `json:"status"` } type podmanMachineEntry struct { Name string `json:"Name"` Running bool `json:"Running"` } var ( resetTargetName string resetBrowserOSDir string resetPortsValue string resetBrowserUserDataDir string ) func init() { resetCmd.Flags().StringVar(&resetTargetName, "target", targetDev, "Reset target: dev, dogfood, or prod") resetCmd.Flags().StringVar(&resetBrowserOSDir, "browseros-dir", "", "Override target BrowserOS state directory") resetCmd.Flags().StringVar(&resetPortsValue, "ports", "", "Override ports as cdp,server,extension") resetCmd.Flags().StringVar(&resetBrowserUserDataDir, "browser-user-data-dir", "", "Override BrowserOS user-data dir to stop") rootCmd.AddCommand(resetCmd) } // runReset walks developers through escalating reset options without hiding the blast radius. func runReset(cmd *cobra.Command, args []string) error { out := cmd.OutOrStdout() reader := bufio.NewReader(os.Stdin) root, err := proc.FindMonorepoRoot() if err != nil { return err } target, err := resolveResetTarget(root, resetTargetOptions{ Target: resetTargetName, BrowserOSDir: resetBrowserOSDir, Ports: resetPortsValue, BrowserUserDataDir: resetBrowserUserDataDir, }) if err != nil { return err } printResetOverview(out, target) if err := ensureTargetStopped(out, target); err != nil { return err } if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Run safe cleanup first?", Body: fmt.Sprintf("This stops %s processes, clears target ports, and removes target temp profiles. It does not touch saved BrowserOS data.", target.Name), Action: "Run safe cleanup for " + target.Name, }); err != nil { return err } else if ok { if err := runSafeCleanup(out, target, safeCleanupOptions{ports: true, temps: true}); err != nil { return err } } limactlPath, err := exec.LookPath("limactl") if err != nil { fmt.Fprintf(out, "%s Lima CLI not found; VM and OpenClaw reset steps are unavailable. Install with %s.\n", warnStyle.Sprint("Skipping:"), commandStyle.Sprint("brew install lima")) if err := maybeResetLegacyPodman(out, reader); err != nil { return err } return maybeDeleteTargetRoot(out, reader, target) } vm, err := findVM(limactlPath, target.LimaHome) if err != nil { fmt.Fprintf(out, "%s could not inspect Lima VMs: %v\n", warnStyle.Sprint("Warning:"), err) if err := maybeResetLegacyPodman(out, reader); err != nil { return err } return maybeDeleteTargetRoot(out, reader, target) } if vm == nil { fmt.Fprintf(out, "%s %s was not found in %s.\n", dimStyle.Sprint("Not found:"), limaVMName, pathStyle.Sprint(target.LimaHome)) if err := maybeResetLegacyPodman(out, reader); err != nil { return err } return maybeDeleteTargetRoot(out, reader, target) } fmt.Fprintf(out, "%s %s %s\n", labelStyle.Sprint("Found VM:"), commandStyle.Sprint(vm.Name), dimStyle.Sprintf("(%s)", vm.Status)) if strings.EqualFold(vm.Status, "Running") { if err := maybeResetOpenClaw(out, reader, limactlPath, target.LimaHome); err != nil { return err } if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Stop VM?", Body: "This shuts down browseros-vm. The VM, containers, images, and profile data stay on disk.", Action: "Stop browseros-vm", }); err != nil { return err } else if ok { if err := runLimactl(out, limactlPath, target.LimaHome, "stop", limaVMName); err != nil { return err } fmt.Fprintln(out, successStyle.Sprint("VM stopped.")) vm.Status = "Stopped" } } else { fmt.Fprintln(out, dimStyle.Sprint("OpenClaw container/image reset needs the VM running; skipping those steps.")) } if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Delete VM?", Body: fmt.Sprintf("This deletes the Lima VM and its container store. %s remains. OpenClaw will be pulled again next time.", target.BrowserOSDir), Action: "Delete browseros-vm", }); err != nil { return err } else if ok { if err := runLimactl(out, limactlPath, target.LimaHome, "delete", "--force", limaVMName); err != nil { return err } fmt.Fprintln(out, successStyle.Sprint("VM deleted.")) } if err := maybeResetLegacyPodman(out, reader); err != nil { return err } return maybeDeleteTargetRoot(out, reader, target) } func printResetOverview(out io.Writer, target resetTarget) { fmt.Fprintln(out, headerStyle.Sprint(target.Title)) fmt.Fprintln(out) fmt.Fprintf(out, "This can reset parts of %s. Pick the smallest reset that matches the problem.\n", pathStyle.Sprint(target.BrowserOSDir)) fmt.Fprintln(out) fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Stop VM:"), dimStyle.Sprint("Shuts down browseros-vm. Keeps data.")) fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Delete VM:"), dimStyle.Sprint("Removes Lima/container state. Keeps the target state root.")) fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw container:"), dimStyle.Sprint("Keeps the downloaded OpenClaw image.")) fmt.Fprintf(out, " %s %s\n", labelStyle.Sprint("Remove OpenClaw image:"), dimStyle.Sprint("Next startup pulls it again.")) fmt.Fprintf(out, " %s %s\n", warnStyle.Sprint(target.DeleteRootLabel), dimStyle.Sprint("Deletes the target BrowserOS state root.")) fmt.Fprintln(out) } func confirmYesNo(out io.Writer, r *bufio.Reader, prompt resetPrompt) (bool, error) { fmt.Fprintln(out, labelStyle.Sprint(prompt.Title)) fmt.Fprintln(out, prompt.Body) if prompt.Action != "" { fmt.Fprintf(out, "%s %s\n", labelStyle.Sprint("Action:"), commandStyle.Sprint(prompt.Action)) } fmt.Fprint(out, labelStyle.Sprint("Continue?")+" [y/N]: ") line, err := r.ReadString('\n') if err != nil && len(line) == 0 { return false, err } line = strings.TrimSpace(strings.ToLower(line)) fmt.Fprintln(out) return line == "y" || line == "yes", nil } func confirmTyped(out io.Writer, r *bufio.Reader, title string, body string, token string) (bool, error) { fmt.Fprintln(out, warnStyle.Sprint(title)) fmt.Fprintln(out, body) for { fmt.Fprintf(out, "%s %s %s: ", labelStyle.Sprint("Type"), commandStyle.Sprint(token), labelStyle.Sprint("to continue")) line, err := r.ReadString('\n') if err != nil && len(line) == 0 { return false, err } if strings.TrimSpace(line) == token { fmt.Fprintln(out) return true, nil } if strings.TrimSpace(line) == "" { fmt.Fprintln(out) return false, nil } fmt.Fprintln(out, warnStyle.Sprint("Confirmation did not match. Press Enter to skip or try again.")) } } func maybeResetOpenClaw(out io.Writer, reader *bufio.Reader, limactlPath string, limaHome string) error { if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Remove OpenClaw container?", Body: "This removes the current gateway/setup containers. The downloaded OpenClaw image stays in the VM.", Action: "nerdctl rm -f " + openClawContainerName + " " + openClawSetupContainer, }); err != nil { return err } else if ok { script := fmt.Sprintf( "nerdctl rm -f %s %s >/dev/null 2>&1 || true", openClawContainerName, openClawSetupContainer, ) if err := runInVM(out, limactlPath, limaHome, "sh", "-lc", script); err != nil { return err } fmt.Fprintln(out, successStyle.Sprint("OpenClaw containers removed if present.")) } if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Remove OpenClaw image?", Body: "This deletes ghcr.io/openclaw/openclaw:2026.4.12 from the VM. Next startup pulls it again.", Action: "nerdctl image rm " + openClawImage, }); err != nil { return err } else if ok { script := fmt.Sprintf("nerdctl image rm %s >/dev/null 2>&1 || true", openClawImage) if err := runInVM(out, limactlPath, limaHome, "sh", "-lc", script); err != nil { return err } fmt.Fprintln(out, successStyle.Sprint("OpenClaw image removed if present.")) } return nil } func maybeDeleteTargetRoot(out io.Writer, reader *bufio.Reader, target resetTarget) error { ok, err := confirmTyped( out, reader, target.DeleteRootLabel, fmt.Sprintf("This deletes %s. %s", pathStyle.Sprint(target.BrowserOSDir), target.DeleteRootBody), "DELETE", ) if err != nil || !ok { return err } if err := validateDevProfileRootForDeletion(target.BrowserOSDir); err != nil { return err } if err := os.RemoveAll(target.BrowserOSDir); err != nil { return err } fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), pathStyle.Sprint(target.BrowserOSDir)) return nil } func maybeResetLegacyPodman(out io.Writer, reader *bufio.Reader) error { podmanPath, err := exec.LookPath("podman") if err != nil { return nil } machines, err := listPodmanMachines(podmanPath) if err != nil { fmt.Fprintf(out, "%s could not inspect legacy Podman machines: %v\n", warnStyle.Sprint("Warning:"), err) return nil } if len(machines) == 0 { return nil } fmt.Fprintln(out, headerStyle.Sprint("Legacy Podman VM cleanup")) fmt.Fprintln(out, "BrowserOS used Podman before the Lima VM runtime. These machines are legacy for this dev flow.") for _, machine := range machines { state := "Stopped" if machine.Running { state = "Running" } fmt.Fprintf(out, " %s %s\n", commandStyle.Sprint(machine.Name), dimStyle.Sprintf("(%s)", state)) } fmt.Fprintln(out, dimStyle.Sprint("Future reset flows can add more legacy cleanup checks here.")) fmt.Fprintln(out) for i := range machines { machine := machines[i] if machine.Running { if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Stop legacy Podman machine?", Body: fmt.Sprintf("This stops legacy Podman machine %s. It does not delete the machine.", machine.Name), Action: "podman machine stop " + machine.Name, }); err != nil { return err } else if ok { if err := runCommand(out, podmanPath, "machine", "stop", machine.Name); err != nil { return err } fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Stopped:"), commandStyle.Sprint(machine.Name)) machines[i].Running = false } } if ok, err := confirmYesNo(out, reader, resetPrompt{ Title: "Delete legacy Podman machine?", Body: fmt.Sprintf("This deletes legacy Podman machine %s. Use this when cleaning up the old VM runtime.", machine.Name), Action: "podman machine rm --force " + machine.Name, }); err != nil { return err } else if ok { if err := runCommand(out, podmanPath, "machine", "rm", "--force", machine.Name); err != nil { return err } fmt.Fprintf(out, "%s %s\n", successStyle.Sprint("Deleted:"), commandStyle.Sprint(machine.Name)) } } return nil } func listPodmanMachines(podmanPath string) ([]podmanMachineEntry, error) { cmd := exec.Command(podmanPath, "machine", "ls", "--format", "json") output, err := cmd.Output() if err != nil { return nil, err } return parsePodmanMachineList(output) } func parsePodmanMachineList(output []byte) ([]podmanMachineEntry, error) { if strings.TrimSpace(string(output)) == "" { return nil, nil } var machines []podmanMachineEntry if err := json.Unmarshal(output, &machines); err != nil { return nil, err } return machines, nil } func validateDevProfileRootForDeletion(root string) error { cleanRoot, err := filepath.Abs(root) if err != nil { return err } if cleanRoot == string(filepath.Separator) { return fmt.Errorf("refusing to delete filesystem root") } home, err := os.UserHomeDir() if err != nil { return err } cleanHome, err := filepath.Abs(home) if err != nil { return err } if cleanRoot == cleanHome { return fmt.Errorf("refusing to delete home directory %s", cleanRoot) } if !isPathInside(cleanRoot, cleanHome) { return fmt.Errorf("refusing to delete path outside home directory: %s", cleanRoot) } return nil } func isPathInside(path string, parent string) bool { rel, err := filepath.Rel(parent, path) if err != nil { return false } return rel != "." && rel != "" && !strings.HasPrefix(rel, "..") && !filepath.IsAbs(rel) } func findVM(limactlPath string, limaHome string) (*limaListEntry, error) { cmd := limactlCommand(limactlPath, limaHome, "list", "--format", "json") output, err := cmd.Output() if err != nil { return nil, err } entries, err := parseLimaListOutput(output) if err != nil { return nil, err } for i := range entries { if entries[i].Name == limaVMName { return &entries[i], nil } } return nil, nil } func parseLimaListOutput(output []byte) ([]limaListEntry, error) { trimmed := strings.TrimSpace(string(output)) if trimmed == "" { return nil, nil } var entries []limaListEntry if err := json.Unmarshal([]byte(trimmed), &entries); err == nil { return entries, nil } var single limaListEntry if err := json.Unmarshal([]byte(trimmed), &single); err == nil { return []limaListEntry{single}, nil } for _, line := range strings.Split(trimmed, "\n") { line = strings.TrimSpace(line) if line == "" { continue } var entry limaListEntry if err := json.Unmarshal([]byte(line), &entry); err != nil { return nil, err } entries = append(entries, entry) } return entries, nil } func runLimactl(out io.Writer, limactlPath string, limaHome string, args ...string) error { cmd := limactlCommand(limactlPath, limaHome, args...) cmd.Stdout = out cmd.Stderr = out return cmd.Run() } func runInVM(out io.Writer, limactlPath string, limaHome string, args ...string) error { shellArgs := limactlShellArgs(args...) return runLimactl(out, limactlPath, limaHome, shellArgs...) } func limactlShellArgs(args ...string) []string { return append([]string{"shell", "--workdir", "/", limaVMName, "--"}, args...) } func limactlCommand(limactlPath string, limaHome string, args ...string) *exec.Cmd { cmd := exec.Command(limactlPath, args...) cmd.Env = append(os.Environ(), "LIMA_HOME="+limaHome) return cmd } func runCommand(out io.Writer, path string, args ...string) error { cmd := exec.Command(path, args...) cmd.Stdout = out cmd.Stderr = out return cmd.Run() }