feat: add reset/uninstall commands
This commit is contained in:
88
src/commands/cleanup-utils.ts
Normal file
88
src/commands/cleanup-utils.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||
import { resolveHomeDir, resolveUserPath } from "../utils.js";
|
||||
|
||||
export type RemovalResult = {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
};
|
||||
|
||||
export function collectWorkspaceDirs(
|
||||
cfg: ClawdbotConfig | undefined,
|
||||
): string[] {
|
||||
const dirs = new Set<string>();
|
||||
const defaults = cfg?.agents?.defaults;
|
||||
if (typeof defaults?.workspace === "string" && defaults.workspace.trim()) {
|
||||
dirs.add(resolveUserPath(defaults.workspace));
|
||||
}
|
||||
const list = Array.isArray(cfg?.agents?.list) ? cfg?.agents?.list : [];
|
||||
for (const agent of list) {
|
||||
const workspace = (agent as { workspace?: unknown }).workspace;
|
||||
if (typeof workspace === "string" && workspace.trim()) {
|
||||
dirs.add(resolveUserPath(workspace));
|
||||
}
|
||||
}
|
||||
if (dirs.size === 0) {
|
||||
dirs.add(resolveDefaultAgentWorkspaceDir());
|
||||
}
|
||||
return [...dirs];
|
||||
}
|
||||
|
||||
export function isPathWithin(child: string, parent: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
||||
}
|
||||
|
||||
function isUnsafeRemovalTarget(target: string): boolean {
|
||||
if (!target.trim()) return true;
|
||||
const resolved = path.resolve(target);
|
||||
const root = path.parse(resolved).root;
|
||||
if (resolved === root) return true;
|
||||
const home = resolveHomeDir();
|
||||
if (home && resolved === path.resolve(home)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function removePath(
|
||||
target: string,
|
||||
runtime: RuntimeEnv,
|
||||
opts?: { dryRun?: boolean; label?: string },
|
||||
): Promise<RemovalResult> {
|
||||
if (!target?.trim()) return { ok: false, skipped: true };
|
||||
const resolved = path.resolve(target);
|
||||
const label = opts?.label ?? resolved;
|
||||
if (isUnsafeRemovalTarget(resolved)) {
|
||||
runtime.error(`Refusing to remove unsafe path: ${label}`);
|
||||
return { ok: false };
|
||||
}
|
||||
if (opts?.dryRun) {
|
||||
runtime.log(`[dry-run] remove ${label}`);
|
||||
return { ok: true, skipped: true };
|
||||
}
|
||||
try {
|
||||
await fs.rm(resolved, { recursive: true, force: true });
|
||||
runtime.log(`Removed ${label}`);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
runtime.error(`Failed to remove ${label}: ${String(err)}`);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgentSessionDirs(
|
||||
stateDir: string,
|
||||
): Promise<string[]> {
|
||||
const root = path.join(stateDir, "agents");
|
||||
try {
|
||||
const entries = await fs.readdir(root, { withFileTypes: true });
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(root, entry.name, "sessions"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
162
src/commands/reset.ts
Normal file
162
src/commands/reset.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { cancel, confirm, isCancel, select } from "@clack/prompts";
|
||||
|
||||
import {
|
||||
loadConfig,
|
||||
resolveConfigPath,
|
||||
resolveOAuthDir,
|
||||
resolveStateDir,
|
||||
isNixMode,
|
||||
} from "../config/config.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { collectWorkspaceDirs, isPathWithin, listAgentSessionDirs, removePath } from "./cleanup-utils.js";
|
||||
|
||||
export type ResetScope = "config" | "config+creds+sessions" | "full";
|
||||
|
||||
export type ResetOptions = {
|
||||
scope?: ResetScope;
|
||||
yes?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
const selectStyled = <T>(params: Parameters<typeof select<T>>[0]) =>
|
||||
select({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) =>
|
||||
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
|
||||
),
|
||||
});
|
||||
|
||||
async function stopGatewayIfRunning(runtime: RuntimeEnv) {
|
||||
if (isNixMode) return;
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway service check failed: ${String(err)}`);
|
||||
return;
|
||||
}
|
||||
if (!loaded) return;
|
||||
try {
|
||||
await service.stop({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway stop failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function resetCommand(runtime: RuntimeEnv, opts: ResetOptions) {
|
||||
const interactive = !opts.nonInteractive;
|
||||
if (!interactive && !opts.yes) {
|
||||
runtime.error("Non-interactive mode requires --yes.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
let scope = opts.scope;
|
||||
if (!scope) {
|
||||
if (!interactive) {
|
||||
runtime.error("Non-interactive mode requires --scope.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const selection = await selectStyled<ResetScope>({
|
||||
message: "Reset scope",
|
||||
options: [
|
||||
{
|
||||
value: "config",
|
||||
label: "Config only",
|
||||
hint: "clawdbot.json",
|
||||
},
|
||||
{
|
||||
value: "config+creds+sessions",
|
||||
label: "Config + credentials + sessions",
|
||||
hint: "keeps workspace + auth profiles",
|
||||
},
|
||||
{
|
||||
value: "full",
|
||||
label: "Full reset",
|
||||
hint: "state dir + workspace",
|
||||
},
|
||||
],
|
||||
initialValue: "config+creds+sessions",
|
||||
});
|
||||
if (isCancel(selection)) {
|
||||
cancel(stylePromptTitle("Reset cancelled.") ?? "Reset cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
scope = selection;
|
||||
}
|
||||
|
||||
if (!["config", "config+creds+sessions", "full"].includes(scope)) {
|
||||
runtime.error(
|
||||
'Invalid --scope. Expected "config", "config+creds+sessions", or "full".',
|
||||
);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactive && !opts.yes) {
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage(`Proceed with ${scope} reset?`),
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
cancel(stylePromptTitle("Reset cancelled.") ?? "Reset cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
const cfg = loadConfig();
|
||||
const stateDir = resolveStateDir();
|
||||
const configPath = resolveConfigPath();
|
||||
const oauthDir = resolveOAuthDir();
|
||||
const configInsideState = isPathWithin(configPath, stateDir);
|
||||
const oauthInsideState = isPathWithin(oauthDir, stateDir);
|
||||
const workspaceDirs = collectWorkspaceDirs(cfg);
|
||||
|
||||
if (scope !== "config") {
|
||||
if (dryRun) {
|
||||
runtime.log("[dry-run] stop gateway service");
|
||||
} else {
|
||||
await stopGatewayIfRunning(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
if (scope === "config") {
|
||||
await removePath(configPath, runtime, { dryRun, label: configPath });
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === "config+creds+sessions") {
|
||||
await removePath(configPath, runtime, { dryRun, label: configPath });
|
||||
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
|
||||
const sessionDirs = await listAgentSessionDirs(stateDir);
|
||||
for (const dir of sessionDirs) {
|
||||
await removePath(dir, runtime, { dryRun, label: dir });
|
||||
}
|
||||
runtime.log("Next: clawdbot onboard --install-daemon");
|
||||
return;
|
||||
}
|
||||
|
||||
if (scope === "full") {
|
||||
await removePath(stateDir, runtime, { dryRun, label: stateDir });
|
||||
if (!configInsideState) {
|
||||
await removePath(configPath, runtime, { dryRun, label: configPath });
|
||||
}
|
||||
if (!oauthInsideState) {
|
||||
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
|
||||
}
|
||||
for (const workspace of workspaceDirs) {
|
||||
await removePath(workspace, runtime, { dryRun, label: workspace });
|
||||
}
|
||||
runtime.log("Next: clawdbot onboard --install-daemon");
|
||||
return;
|
||||
}
|
||||
}
|
||||
193
src/commands/uninstall.ts
Normal file
193
src/commands/uninstall.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { cancel, confirm, isCancel, multiselect } from "@clack/prompts";
|
||||
import path from "node:path";
|
||||
|
||||
import { loadConfig, resolveConfigPath, resolveOAuthDir, resolveStateDir, isNixMode } from "../config/config.js";
|
||||
import { resolveGatewayService } from "../daemon/service.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { stylePromptHint, stylePromptMessage, stylePromptTitle } from "../terminal/prompt-style.js";
|
||||
import { resolveHomeDir } from "../utils.js";
|
||||
import { collectWorkspaceDirs, isPathWithin, removePath } from "./cleanup-utils.js";
|
||||
|
||||
type UninstallScope = "service" | "state" | "workspace" | "app";
|
||||
|
||||
export type UninstallOptions = {
|
||||
service?: boolean;
|
||||
state?: boolean;
|
||||
workspace?: boolean;
|
||||
app?: boolean;
|
||||
all?: boolean;
|
||||
yes?: boolean;
|
||||
nonInteractive?: boolean;
|
||||
dryRun?: boolean;
|
||||
};
|
||||
|
||||
const multiselectStyled = <T>(
|
||||
params: Parameters<typeof multiselect<T>>[0],
|
||||
) =>
|
||||
multiselect({
|
||||
...params,
|
||||
message: stylePromptMessage(params.message),
|
||||
options: params.options.map((opt) =>
|
||||
opt.hint === undefined ? opt : { ...opt, hint: stylePromptHint(opt.hint) },
|
||||
),
|
||||
});
|
||||
|
||||
function buildScopeSelection(opts: UninstallOptions): {
|
||||
scopes: Set<UninstallScope>;
|
||||
hadExplicit: boolean;
|
||||
} {
|
||||
const hadExplicit = Boolean(
|
||||
opts.all || opts.service || opts.state || opts.workspace || opts.app,
|
||||
);
|
||||
const scopes = new Set<UninstallScope>();
|
||||
if (opts.all || opts.service) scopes.add("service");
|
||||
if (opts.all || opts.state) scopes.add("state");
|
||||
if (opts.all || opts.workspace) scopes.add("workspace");
|
||||
if (opts.all || opts.app) scopes.add("app");
|
||||
return { scopes, hadExplicit };
|
||||
}
|
||||
|
||||
async function stopAndUninstallService(runtime: RuntimeEnv): Promise<boolean> {
|
||||
if (isNixMode) {
|
||||
runtime.error("Nix mode detected; daemon uninstall is disabled.");
|
||||
return false;
|
||||
}
|
||||
const service = resolveGatewayService();
|
||||
const profile = process.env.CLAWDBOT_PROFILE;
|
||||
let loaded = false;
|
||||
try {
|
||||
loaded = await service.isLoaded({ profile });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway service check failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
if (!loaded) {
|
||||
runtime.log(`Gateway service ${service.notLoadedText}.`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await service.stop({ profile, stdout: process.stdout });
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway stop failed: ${String(err)}`);
|
||||
}
|
||||
try {
|
||||
await service.uninstall({ env: process.env, stdout: process.stdout });
|
||||
return true;
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway uninstall failed: ${String(err)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMacApp(runtime: RuntimeEnv, dryRun?: boolean) {
|
||||
if (process.platform !== "darwin") return;
|
||||
await removePath("/Applications/Clawdbot.app", runtime, {
|
||||
dryRun,
|
||||
label: "/Applications/Clawdbot.app",
|
||||
});
|
||||
}
|
||||
|
||||
export async function uninstallCommand(
|
||||
runtime: RuntimeEnv,
|
||||
opts: UninstallOptions,
|
||||
) {
|
||||
const { scopes, hadExplicit } = buildScopeSelection(opts);
|
||||
const interactive = !opts.nonInteractive;
|
||||
if (!interactive && !opts.yes) {
|
||||
runtime.error("Non-interactive mode requires --yes.");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hadExplicit) {
|
||||
if (!interactive) {
|
||||
runtime.error("Non-interactive mode requires explicit scopes (use --all).");
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
const selection = await multiselectStyled<UninstallScope>({
|
||||
message: "Uninstall which components?",
|
||||
options: [
|
||||
{
|
||||
value: "service",
|
||||
label: "Gateway service",
|
||||
hint: "launchd / systemd / schtasks",
|
||||
},
|
||||
{ value: "state", label: "State + config", hint: "~/.clawdbot" },
|
||||
{ value: "workspace", label: "Workspace", hint: "agent files" },
|
||||
{ value: "app", label: "macOS app", hint: "/Applications/Clawdbot.app" },
|
||||
],
|
||||
initialValues: ["service", "state", "workspace"],
|
||||
});
|
||||
if (isCancel(selection)) {
|
||||
cancel(stylePromptTitle("Uninstall cancelled.") ?? "Uninstall cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
for (const value of selection) scopes.add(value);
|
||||
}
|
||||
|
||||
if (scopes.size === 0) {
|
||||
runtime.log("Nothing selected.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (interactive && !opts.yes) {
|
||||
const ok = await confirm({
|
||||
message: stylePromptMessage("Proceed with uninstall?"),
|
||||
});
|
||||
if (isCancel(ok) || !ok) {
|
||||
cancel(stylePromptTitle("Uninstall cancelled.") ?? "Uninstall cancelled.");
|
||||
runtime.exit(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
const cfg = loadConfig();
|
||||
const stateDir = resolveStateDir();
|
||||
const configPath = resolveConfigPath();
|
||||
const oauthDir = resolveOAuthDir();
|
||||
const configInsideState = isPathWithin(configPath, stateDir);
|
||||
const oauthInsideState = isPathWithin(oauthDir, stateDir);
|
||||
const workspaceDirs = collectWorkspaceDirs(cfg);
|
||||
|
||||
if (scopes.has("service")) {
|
||||
if (dryRun) {
|
||||
runtime.log("[dry-run] remove gateway service");
|
||||
} else {
|
||||
await stopAndUninstallService(runtime);
|
||||
}
|
||||
}
|
||||
|
||||
if (scopes.has("state")) {
|
||||
await removePath(stateDir, runtime, { dryRun, label: stateDir });
|
||||
if (!configInsideState) {
|
||||
await removePath(configPath, runtime, { dryRun, label: configPath });
|
||||
}
|
||||
if (!oauthInsideState) {
|
||||
await removePath(oauthDir, runtime, { dryRun, label: oauthDir });
|
||||
}
|
||||
}
|
||||
|
||||
if (scopes.has("workspace")) {
|
||||
for (const workspace of workspaceDirs) {
|
||||
await removePath(workspace, runtime, { dryRun, label: workspace });
|
||||
}
|
||||
}
|
||||
|
||||
if (scopes.has("app")) {
|
||||
await removeMacApp(runtime, dryRun);
|
||||
}
|
||||
|
||||
runtime.log("CLI still installed. Remove via npm/pnpm if desired.");
|
||||
|
||||
if (scopes.has("state") && !scopes.has("workspace")) {
|
||||
const home = resolveHomeDir();
|
||||
if (home && workspaceDirs.some((dir) => dir.startsWith(path.resolve(home)))) {
|
||||
runtime.log(
|
||||
"Tip: workspaces were preserved. Re-run with --workspace to remove them.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user