From 941ed143852c80804340e36e8fcbc977bb39aeb8 Mon Sep 17 00:00:00 2001 From: SC-Claw Date: Mon, 9 Mar 2026 02:45:04 +0800 Subject: [PATCH] feat: surface backup guidance for destructive flows --- src/commands/reset.test.ts | 69 ++++++++++++++++++++++++++++++++++ src/commands/reset.ts | 5 +++ src/commands/uninstall.test.ts | 66 ++++++++++++++++++++++++++++++++ src/commands/uninstall.ts | 9 +++++ 4 files changed, 149 insertions(+) create mode 100644 src/commands/reset.test.ts create mode 100644 src/commands/uninstall.test.ts diff --git a/src/commands/reset.test.ts b/src/commands/reset.test.ts new file mode 100644 index 00000000000..b97545a4371 --- /dev/null +++ b/src/commands/reset.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const listAgentSessionDirs = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + listAgentSessionDirs, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { resetCommand } = await import("./reset.js"); + +describe("resetCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + listAgentSessionDirs.mockResolvedValue(["/tmp/.openclaw/agents/main/sessions"]); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before state-destructive reset scopes", async () => { + await resetCommand(runtime, { + scope: "config+creds+sessions", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for config-only reset", async () => { + await resetCommand(runtime, { + scope: "config", + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/reset.ts b/src/commands/reset.ts index 1f9ba9a7997..596d80a139a 100644 --- a/src/commands/reset.ts +++ b/src/commands/reset.ts @@ -44,6 +44,10 @@ async function stopGatewayIfRunning(runtime: RuntimeEnv) { } } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { const interactive = !opts.nonInteractive; if (!interactive && !opts.yes) { @@ -110,6 +114,7 @@ export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) { resolveCleanupPlanFromDisk(); if (scope !== "config") { + logBackupRecommendation(runtime); if (dryRun) { runtime.log("[dry-run] stop gateway service"); } else { diff --git a/src/commands/uninstall.test.ts b/src/commands/uninstall.test.ts new file mode 100644 index 00000000000..bdf0efe1354 --- /dev/null +++ b/src/commands/uninstall.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createNonExitingRuntime } from "../runtime.js"; + +const resolveCleanupPlanFromDisk = vi.fn(); +const removePath = vi.fn(); +const removeStateAndLinkedPaths = vi.fn(); +const removeWorkspaceDirs = vi.fn(); + +vi.mock("../config/config.js", () => ({ + isNixMode: false, +})); + +vi.mock("./cleanup-plan.js", () => ({ + resolveCleanupPlanFromDisk, +})); + +vi.mock("./cleanup-utils.js", () => ({ + removePath, + removeStateAndLinkedPaths, + removeWorkspaceDirs, +})); + +const { uninstallCommand } = await import("./uninstall.js"); + +describe("uninstallCommand", () => { + const runtime = createNonExitingRuntime(); + + beforeEach(() => { + vi.clearAllMocks(); + resolveCleanupPlanFromDisk.mockReturnValue({ + stateDir: "/tmp/.openclaw", + configPath: "/tmp/.openclaw/openclaw.json", + oauthDir: "/tmp/.openclaw/credentials", + configInsideState: true, + oauthInsideState: true, + workspaceDirs: ["/tmp/.openclaw/workspace"], + }); + removePath.mockResolvedValue({ ok: true }); + removeStateAndLinkedPaths.mockResolvedValue(undefined); + removeWorkspaceDirs.mockResolvedValue(undefined); + vi.spyOn(runtime, "log").mockImplementation(() => {}); + vi.spyOn(runtime, "error").mockImplementation(() => {}); + }); + + it("recommends creating a backup before removing state or workspaces", async () => { + await uninstallCommand(runtime, { + state: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); + + it("does not recommend backup for service-only uninstall", async () => { + await uninstallCommand(runtime, { + service: true, + yes: true, + nonInteractive: true, + dryRun: true, + }); + + expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("openclaw backup create")); + }); +}); diff --git a/src/commands/uninstall.ts b/src/commands/uninstall.ts index aa91a321d00..5f03eb1cefa 100644 --- a/src/commands/uninstall.ts +++ b/src/commands/uninstall.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { cancel, confirm, isCancel, multiselect } from "@clack/prompts"; +import { formatCliCommand } from "../cli/command-format.js"; import { isNixMode } from "../config/config.js"; import { resolveGatewayService } from "../daemon/service.js"; import type { RuntimeEnv } from "../runtime.js"; @@ -92,6 +93,10 @@ async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) { }); } +function logBackupRecommendation(runtime: RuntimeEnv) { + runtime.log(`Recommended first: ${formatCliCommand("openclaw backup create")}`); +} + export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptions) { const { scopes, hadExplicit } = buildScopeSelection(opts); const interactive = !opts.nonInteractive; @@ -155,6 +160,10 @@ export async function uninstallCommand(runtime: RuntimeEnv, opts: UninstallOptio const { stateDir, configPath, oauthDir, configInsideState, oauthInsideState, workspaceDirs } = resolveCleanupPlanFromDisk(); + if (scopes.has("state") || scopes.has("workspace")) { + logBackupRecommendation(runtime); + } + if (scopes.has("service")) { if (dryRun) { runtime.log("[dry-run] remove gateway service");