mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
feat(workspace): add safe workspace reset command
This commit is contained in:
@@ -31,7 +31,7 @@ async function loadDevTemplate(name: string, fallback: string): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
export const resolveDevWorkspaceDir = (env: NodeJS.ProcessEnv = process.env): string => {
|
||||
const baseDir = resolveDefaultAgentWorkspaceDir(env, os.homedir);
|
||||
const profile = normalizeOptionalLowercaseString(env.OPENCLAW_PROFILE);
|
||||
if (profile === "dev") {
|
||||
@@ -54,7 +54,7 @@ async function writeFileIfMissing(filePath: string, content: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureDevWorkspace(dir: string) {
|
||||
export async function ensureDevWorkspace(dir: string) {
|
||||
const resolvedDir = resolveUserPath(dir);
|
||||
await fs.promises.mkdir(resolvedDir, { recursive: true });
|
||||
|
||||
|
||||
@@ -76,6 +76,11 @@ const coreEntrySpecs: readonly CommandGroupDescriptorSpec<
|
||||
loadModule: () => import("./register.backup.js"),
|
||||
exportName: "registerBackupCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["workspace"],
|
||||
loadModule: () => import("./register.workspace.js"),
|
||||
exportName: "registerWorkspaceCommand",
|
||||
},
|
||||
{
|
||||
commandNames: ["doctor", "dashboard", "reset", "uninstall"],
|
||||
loadModule: () => import("./register.maintenance.js"),
|
||||
|
||||
@@ -18,6 +18,13 @@ vi.mock("./register.backup.js", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./register.workspace.js", () => ({
|
||||
registerWorkspaceCommand: (program: Command) => {
|
||||
const workspace = program.command("workspace");
|
||||
workspace.command("reset");
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./register.maintenance.js", () => ({
|
||||
registerMaintenanceCommands: (program: Command) => {
|
||||
program.command("doctor");
|
||||
@@ -70,6 +77,7 @@ describe("command-registry", () => {
|
||||
expect(names).toContain("mcp");
|
||||
expect(names).toContain("agent");
|
||||
expect(names).toContain("agents");
|
||||
expect(names).toContain("workspace");
|
||||
});
|
||||
|
||||
it("returns only commands that support subcommands", () => {
|
||||
@@ -77,6 +85,7 @@ describe("command-registry", () => {
|
||||
expect(names).toContain("config");
|
||||
expect(names).toContain("agents");
|
||||
expect(names).toContain("backup");
|
||||
expect(names).toContain("workspace");
|
||||
expect(names).toContain("mcp");
|
||||
expect(names).toContain("sessions");
|
||||
expect(names).toContain("tasks");
|
||||
|
||||
@@ -50,6 +50,11 @@ const coreCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
description: "Uninstall the gateway service + local data (CLI remains)",
|
||||
hasSubcommands: false,
|
||||
},
|
||||
{
|
||||
name: "workspace",
|
||||
description: "Manage agent workspaces",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "message",
|
||||
description: "Send, read, and manage messages",
|
||||
|
||||
57
src/cli/program/register.workspace.test.ts
Normal file
57
src/cli/program/register.workspace.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { registerWorkspaceCommand } from "./register.workspace.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
workspaceResetCommand: vi.fn(),
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../commands/workspace.js", () => ({
|
||||
workspaceResetCommand: mocks.workspaceResetCommand,
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
}));
|
||||
|
||||
describe("registerWorkspaceCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("passes workspace reset options through to the command", async () => {
|
||||
const program = new Command();
|
||||
registerWorkspaceCommand(program);
|
||||
|
||||
await program.parseAsync(
|
||||
[
|
||||
"workspace",
|
||||
"reset",
|
||||
"--workspace",
|
||||
"/tmp/ws",
|
||||
"--agent",
|
||||
"ops",
|
||||
"--include-sessions",
|
||||
"--yes",
|
||||
"--dry-run",
|
||||
],
|
||||
{ from: "user" },
|
||||
);
|
||||
|
||||
expect(mocks.workspaceResetCommand).toHaveBeenCalledWith(
|
||||
mocks.runtime,
|
||||
expect.objectContaining({
|
||||
workspace: "/tmp/ws",
|
||||
agent: "ops",
|
||||
includeSessions: true,
|
||||
yes: true,
|
||||
dryRun: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
57
src/cli/program/register.workspace.ts
Normal file
57
src/cli/program/register.workspace.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Command } from "commander";
|
||||
import { workspaceResetCommand } from "../../commands/workspace.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { runCommandWithRuntime } from "../cli-utils.js";
|
||||
import { formatHelpExamples } from "../help-format.js";
|
||||
|
||||
export function registerWorkspaceCommand(program: Command) {
|
||||
const workspace = program
|
||||
.command("workspace")
|
||||
.description("Manage agent workspaces")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/workspace", "docs.openclaw.ai/cli/workspace")}\n`,
|
||||
);
|
||||
|
||||
workspace
|
||||
.command("reset")
|
||||
.description("Reset only the active agent workspace and reseed it as a fresh agent")
|
||||
.option("--workspace <dir>", "Explicit workspace directory to reset")
|
||||
.option("--agent <id>", "Agent id to resolve workspace/sessions from the active config")
|
||||
.option("--include-sessions", "Also clear this agent's session transcripts", false)
|
||||
.option("--yes", "Skip the confirmation prompt", false)
|
||||
.option("--dry-run", "Print the reset plan without moving anything", false)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
|
||||
["openclaw workspace reset", "Trash and reseed only the active workspace."],
|
||||
[
|
||||
"openclaw workspace reset --include-sessions",
|
||||
"Also clear the active agent's session transcripts.",
|
||||
],
|
||||
[
|
||||
"openclaw workspace reset --agent ops",
|
||||
"Reset the workspace resolved for a specific agent id.",
|
||||
],
|
||||
[
|
||||
"openclaw workspace reset --workspace ~/tmp/test-workspace --dry-run",
|
||||
"Preview a custom workspace-only reset.",
|
||||
],
|
||||
])}`,
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runCommandWithRuntime(defaultRuntime, async () => {
|
||||
await workspaceResetCommand(defaultRuntime, {
|
||||
workspace: opts.workspace as string | undefined,
|
||||
agent: opts.agent as string | undefined,
|
||||
includeSessions: Boolean(opts.includeSessions),
|
||||
yes: Boolean(opts.yes),
|
||||
dryRun: Boolean(opts.dryRun),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
moveToTrash,
|
||||
normalizeGatewayTokenInput,
|
||||
openUrl,
|
||||
probeGatewayReachable,
|
||||
@@ -223,6 +226,43 @@ describe("normalizeGatewayTokenInput", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("moveToTrash", () => {
|
||||
it("falls back to mv into ~/.Trash when trash exits successfully but leaves the path in place", async () => {
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-home-"));
|
||||
const targetDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-trash-target-"));
|
||||
const target = path.join(targetDir, "workspace");
|
||||
await fs.mkdir(target, { recursive: true });
|
||||
await fs.writeFile(path.join(target, "stale.txt"), "old", "utf-8");
|
||||
vi.spyOn(os, "homedir").mockReturnValue(tempHome);
|
||||
|
||||
mocks.runCommandWithTimeout.mockImplementationOnce(async () => ({
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
}));
|
||||
mocks.runCommandWithTimeout.mockImplementationOnce(async (argv) => {
|
||||
await fs.rename(argv[1], argv[2]);
|
||||
return {
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
code: 0,
|
||||
signal: null,
|
||||
killed: false,
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
await moveToTrash(target, runtime as never);
|
||||
|
||||
await expect(fs.access(target)).rejects.toThrow();
|
||||
await expect(fs.access(path.join(tempHome, ".Trash", "workspace"))).resolves.toBeUndefined();
|
||||
expect(runtime.log).toHaveBeenCalledWith(`Moved to Trash: ${target}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateGatewayPasswordInput", () => {
|
||||
it("requires a non-empty password", () => {
|
||||
expect(validateGatewayPasswordInput("")).toBe("Required");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { inspect } from "node:util";
|
||||
import { cancel, isCancel } from "@clack/prompts";
|
||||
@@ -187,6 +188,28 @@ export function resolveNodeManagerOptions(): Array<{
|
||||
];
|
||||
}
|
||||
|
||||
async function pathExists(pathname: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(pathname);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTrashDestination(pathname: string): Promise<string> {
|
||||
const trashDir = path.join(os.homedir(), ".Trash");
|
||||
await fs.mkdir(trashDir, { recursive: true });
|
||||
const baseName = path.basename(pathname);
|
||||
let candidate = path.join(trashDir, baseName);
|
||||
let suffix = 1;
|
||||
while (await pathExists(candidate)) {
|
||||
candidate = path.join(trashDir, `${baseName}.${suffix}`);
|
||||
suffix += 1;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promise<void> {
|
||||
if (!pathname) {
|
||||
return;
|
||||
@@ -198,8 +221,26 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
|
||||
}
|
||||
try {
|
||||
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
if (!(await pathExists(pathname))) {
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to a verified mv-based fallback below.
|
||||
}
|
||||
|
||||
try {
|
||||
const destination = await resolveTrashDestination(pathname);
|
||||
await runCommandWithTimeout(["mv", pathname, destination], { timeoutMs: 5000 });
|
||||
if (!(await pathExists(pathname))) {
|
||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Surface the manual action guidance below.
|
||||
}
|
||||
|
||||
if (await pathExists(pathname)) {
|
||||
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
|
||||
}
|
||||
}
|
||||
|
||||
195
src/commands/workspace.test.ts
Normal file
195
src/commands/workspace.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { workspaceResetCommand } from "./workspace.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
readBestEffortConfig: vi.fn(async () => ({})),
|
||||
resolveDefaultAgentId: vi.fn(() => "main"),
|
||||
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace-main"),
|
||||
resolveSessionTranscriptsDirForAgent: vi.fn(() => "/tmp/sessions-main"),
|
||||
ensureAgentWorkspace: vi.fn(async ({ dir }: { dir: string }) => {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "BOOTSTRAP.md"), "# BOOTSTRAP\n", "utf-8");
|
||||
return { dir };
|
||||
}),
|
||||
ensureDevWorkspace: vi.fn(async (dir: string) => {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(path.join(dir, "AGENTS.md"), "# DEV AGENTS\n", "utf-8");
|
||||
}),
|
||||
resolveDevWorkspaceDir: vi.fn(() => "/tmp/workspace-dev"),
|
||||
moveToTrash: vi.fn(async (target: string) => {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
}),
|
||||
confirm: vi.fn(async () => true),
|
||||
cancel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
readBestEffortConfig: mocks.readBestEffortConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
|
||||
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("../config/sessions/paths.js", () => ({
|
||||
resolveSessionTranscriptsDirForAgent: mocks.resolveSessionTranscriptsDirForAgent,
|
||||
}));
|
||||
|
||||
vi.mock("../agents/workspace.js", () => ({
|
||||
ensureAgentWorkspace: mocks.ensureAgentWorkspace,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/gateway-cli/dev.js", () => ({
|
||||
ensureDevWorkspace: mocks.ensureDevWorkspace,
|
||||
resolveDevWorkspaceDir: mocks.resolveDevWorkspaceDir,
|
||||
}));
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
moveToTrash: mocks.moveToTrash,
|
||||
}));
|
||||
|
||||
vi.mock("@clack/prompts", () => ({
|
||||
confirm: mocks.confirm,
|
||||
cancel: mocks.cancel,
|
||||
isCancel: (value: unknown) => value === Symbol.for("clack.cancel"),
|
||||
}));
|
||||
|
||||
describe("workspaceResetCommand", () => {
|
||||
let tempRoot: string;
|
||||
let runtime: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
exit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-reset-"));
|
||||
runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it("resets only the workspace by default and reseeds it", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(workspaceDir, "stale.txt"), "old", "utf-8");
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, {});
|
||||
|
||||
expect(mocks.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalledWith(sessionsDir, runtime);
|
||||
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
|
||||
"BOOTSTRAP",
|
||||
);
|
||||
expect(await fs.stat(sessionsDir)).toBeDefined();
|
||||
});
|
||||
|
||||
it("also resets sessions when --include-sessions is enabled", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
await fs.writeFile(path.join(sessionsDir, "old.log"), "old", "utf-8");
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { includeSessions: true });
|
||||
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(sessionsDir, runtime);
|
||||
expect(await fs.readFile(path.join(workspaceDir, "BOOTSTRAP.md"), "utf-8")).toContain(
|
||||
"BOOTSTRAP",
|
||||
);
|
||||
expect(await fs.readdir(sessionsDir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects combining --workspace with --include-sessions", async () => {
|
||||
const customWorkspace = path.join(tempRoot, "custom-workspace");
|
||||
await fs.mkdir(customWorkspace, { recursive: true });
|
||||
|
||||
await expect(
|
||||
workspaceResetCommand(runtime as never, {
|
||||
workspace: customWorkspace,
|
||||
includeSessions: true,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
|
||||
);
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureDevWorkspace).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports dry-run without modifying workspace or sessions", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
const sessionsDir = path.join(tempRoot, "sessions-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.resolveSessionTranscriptsDirForAgent.mockReturnValue(sessionsDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { includeSessions: true, dryRun: true });
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(await fs.stat(workspaceDir)).toBeDefined();
|
||||
expect(await fs.stat(sessionsDir)).toBeDefined();
|
||||
});
|
||||
|
||||
it("skips confirmation when --yes is enabled", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { yes: true });
|
||||
|
||||
expect(mocks.confirm).not.toHaveBeenCalled();
|
||||
expect(mocks.moveToTrash).toHaveBeenCalledWith(workspaceDir, runtime);
|
||||
});
|
||||
|
||||
it("cancels without touching anything when confirmation is declined", async () => {
|
||||
const workspaceDir = path.join(tempRoot, "workspace-main");
|
||||
await fs.mkdir(workspaceDir, { recursive: true });
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(workspaceDir);
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
|
||||
await workspaceResetCommand(runtime as never, {});
|
||||
|
||||
expect(mocks.moveToTrash).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).toHaveBeenCalledWith(0);
|
||||
expect(runtime.log).not.toHaveBeenCalledWith(expect.stringContaining("Workspace reseeded"));
|
||||
});
|
||||
|
||||
it("reuses the dev reseed helper for the active dev workspace", async () => {
|
||||
const devWorkspace = path.join(tempRoot, "workspace-dev");
|
||||
await fs.mkdir(devWorkspace, { recursive: true });
|
||||
mocks.resolveAgentWorkspaceDir.mockReturnValue(devWorkspace);
|
||||
mocks.resolveDevWorkspaceDir.mockReturnValue(devWorkspace);
|
||||
|
||||
await workspaceResetCommand(runtime as never, { yes: true });
|
||||
|
||||
expect(mocks.ensureDevWorkspace).toHaveBeenCalledWith(devWorkspace);
|
||||
expect(mocks.ensureAgentWorkspace).not.toHaveBeenCalled();
|
||||
expect(await fs.readFile(path.join(devWorkspace, "AGENTS.md"), "utf-8")).toContain(
|
||||
"DEV AGENTS",
|
||||
);
|
||||
});
|
||||
});
|
||||
134
src/commands/workspace.ts
Normal file
134
src/commands/workspace.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { cancel, confirm, isCancel } from "@clack/prompts";
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import { ensureAgentWorkspace } from "../agents/workspace.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { ensureDevWorkspace, resolveDevWorkspaceDir } from "../cli/gateway-cli/dev.js";
|
||||
import { readBestEffortConfig } from "../config/config.js";
|
||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions/paths.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
||||
import { moveToTrash } from "./onboard-helpers.js";
|
||||
|
||||
export type WorkspaceResetOptions = {
|
||||
workspace?: string;
|
||||
agent?: string;
|
||||
includeSessions?: boolean;
|
||||
yes?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
function hasValue(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
async function pathExists(target: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function describeResetPlan(params: {
|
||||
workspaceDir: string;
|
||||
sessionsDir: string;
|
||||
includeSessions: boolean;
|
||||
}): string[] {
|
||||
return [
|
||||
`Workspace: ${shortenHomePath(params.workspaceDir)}`,
|
||||
params.includeSessions ? `Sessions: ${shortenHomePath(params.sessionsDir)}` : undefined,
|
||||
"Preserves: config, credentials, channels, and gateway auth.",
|
||||
].filter((line): line is string => Boolean(line));
|
||||
}
|
||||
|
||||
function isActiveDevWorkspaceTarget(workspaceDir: string): boolean {
|
||||
return resolveUserPath(workspaceDir) === resolveUserPath(resolveDevWorkspaceDir(process.env));
|
||||
}
|
||||
|
||||
export async function workspaceResetCommand(
|
||||
runtime: RuntimeEnv,
|
||||
opts: WorkspaceResetOptions,
|
||||
): Promise<void> {
|
||||
const cfg = await readBestEffortConfig();
|
||||
const hasExplicitWorkspace = hasValue(opts.workspace);
|
||||
const agentId = hasValue(opts.agent) ? opts.agent.trim() : resolveDefaultAgentId(cfg);
|
||||
const workspaceDir = hasExplicitWorkspace
|
||||
? resolveUserPath(opts.workspace!.trim())
|
||||
: resolveAgentWorkspaceDir(cfg, agentId);
|
||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId);
|
||||
const includeSessions = Boolean(opts.includeSessions);
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
|
||||
if (hasExplicitWorkspace && includeSessions) {
|
||||
throw new Error(
|
||||
"--include-sessions cannot be combined with --workspace; sessions are resolved from configured agent state only.",
|
||||
);
|
||||
}
|
||||
|
||||
for (const line of describeResetPlan({ workspaceDir, sessionsDir, includeSessions })) {
|
||||
runtime.log(line);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
runtime.log(`[dry-run] trash ${shortenHomePath(workspaceDir)}`);
|
||||
if (includeSessions) {
|
||||
runtime.log(`[dry-run] trash ${shortenHomePath(sessionsDir)}`);
|
||||
}
|
||||
runtime.log(
|
||||
`[dry-run] reseed ${shortenHomePath(workspaceDir)} with default workspace files and BOOTSTRAP.md`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!opts.yes) {
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(
|
||||
`Trash and reseed ${shortenHomePath(workspaceDir)}${includeSessions ? " and clear this agent's sessions" : ""}?`,
|
||||
),
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
cancel(stylePromptTitle("Workspace reset cancelled.") ?? "Workspace reset cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await moveToTrash(workspaceDir, runtime);
|
||||
if (await pathExists(workspaceDir)) {
|
||||
throw new Error(
|
||||
`Workspace reset did not remove ${shortenHomePath(workspaceDir)}. Move it manually or retry.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (includeSessions) {
|
||||
await moveToTrash(sessionsDir, runtime);
|
||||
if (await pathExists(sessionsDir)) {
|
||||
throw new Error(
|
||||
`Session reset did not remove ${shortenHomePath(sessionsDir)}. Move it manually or retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isActiveDevWorkspaceTarget(workspaceDir)) {
|
||||
await ensureDevWorkspace(workspaceDir);
|
||||
} else {
|
||||
await ensureAgentWorkspace({
|
||||
dir: workspaceDir,
|
||||
ensureBootstrapFiles: true,
|
||||
});
|
||||
}
|
||||
runtime.log(`Workspace reseeded: ${shortenHomePath(workspaceDir)}`);
|
||||
|
||||
if (includeSessions) {
|
||||
await fs.mkdir(sessionsDir, { recursive: true });
|
||||
runtime.log(`Sessions reset: ${shortenHomePath(sessionsDir)}`);
|
||||
}
|
||||
|
||||
runtime.log("Workspace reset complete.");
|
||||
runtime.log("Recommended next steps:");
|
||||
runtime.log(`- ${formatCliCommand("openclaw onboard")}`);
|
||||
runtime.log(`- ${formatCliCommand("openclaw gateway run")}`);
|
||||
}
|
||||
Reference in New Issue
Block a user