aboutsummaryrefslogtreecommitdiffstats
path: root/apps/cli/src
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-09-14 13:01:19 +0000
committerMohamed Bassem <me@mbassem.com>2025-09-14 13:01:19 +0000
commitbc0e7461503c6b6d9ef7bec3bf8607f5e33a896b (patch)
treeb39f115346935d559e257a2e9b578087afd4a4b5 /apps/cli/src
parent783f72cb91b436e8ee6d7349da4cf72dc3219aa1 (diff)
downloadkarakeep-bc0e7461503c6b6d9ef7bec3bf8607f5e33a896b.tar.zst
feat(cli): Implement a wipe command in the CLI
Diffstat (limited to 'apps/cli/src')
-rw-r--r--apps/cli/src/commands/wipe.ts386
-rw-r--r--apps/cli/src/index.ts2
2 files changed, 388 insertions, 0 deletions
diff --git a/apps/cli/src/commands/wipe.ts b/apps/cli/src/commands/wipe.ts
new file mode 100644
index 00000000..34281e2b
--- /dev/null
+++ b/apps/cli/src/commands/wipe.ts
@@ -0,0 +1,386 @@
+import { stdin as input, stdout as output } from "node:process";
+import readline from "node:readline/promises";
+import { getGlobalOptions } from "@/lib/globals";
+import { printErrorMessageWithReason, printStatusMessage } from "@/lib/output";
+import { getAPIClient } from "@/lib/trpc";
+import { Command } from "@commander-js/extra-typings";
+import chalk from "chalk";
+
+import { MAX_NUM_BOOKMARKS_PER_PAGE } from "@karakeep/shared/types/bookmarks";
+import { ZCursor } from "@karakeep/shared/types/pagination";
+
+const OK = chalk.green("✓");
+const FAIL = chalk.red("✗");
+const DOTS = chalk.gray("…");
+
+function line(msg: string) {
+ console.log(msg);
+}
+
+function stepStart(title: string) {
+ console.log(`${chalk.cyan(title)} ${DOTS}`);
+}
+
+function stepEndSuccess(extra?: string) {
+ process.stdout.write(`${OK}${extra ? " " + chalk.gray(extra) : ""}\n`);
+}
+
+function stepEndFail(extra?: string) {
+ process.stdout.write(`${FAIL}${extra ? " " + chalk.gray(extra) : ""}\n`);
+}
+
+function progressUpdate(
+ prefix: string,
+ current: number,
+ total?: number,
+ suffix?: string,
+) {
+ const totalPart = total != null ? `/${total}` : "";
+ const text = `${chalk.gray(prefix)} ${current}${totalPart}${suffix ? " " + chalk.gray(suffix) : ""}`;
+ if (process.stdout.isTTY) {
+ try {
+ process.stdout.clearLine(0);
+ process.stdout.cursorTo(0);
+ process.stdout.write(text);
+ return;
+ } catch {
+ // ignore failures
+ }
+ }
+ console.log(text);
+}
+
+function progressDone() {
+ process.stdout.write("\n");
+}
+
+export const wipeCmd = new Command()
+ .name("wipe")
+ .description("wipe all data for the current user from the server")
+ .option("-y, --yes", "skip confirmation prompt")
+ .option(
+ "--batch-size <n>",
+ `number of bookmarks per page (max ${MAX_NUM_BOOKMARKS_PER_PAGE})`,
+ (v) => Math.min(Number(v || 50), MAX_NUM_BOOKMARKS_PER_PAGE),
+ 50,
+ )
+ .action(async (opts) => {
+ const globals = getGlobalOptions();
+ const api = getAPIClient();
+
+ if (!opts.yes) {
+ const rl = readline.createInterface({ input, output });
+ const answer = (
+ await rl.question(
+ `This will permanently delete ALL your data on "${globals.serverAddr}". Proceed? (yes/no): `,
+ )
+ )
+ .trim()
+ .toLowerCase();
+ rl.close();
+ if (answer !== "y" && answer !== "yes") {
+ printStatusMessage(false, "Wipe aborted by user");
+ return;
+ }
+ }
+
+ try {
+ line("");
+ line(`${chalk.bold("Karakeep Wipe")}`);
+ line(`${chalk.gray("Server:")} ${globals.serverAddr}`);
+ line("");
+
+ // Pre-fetch stats for user feedback
+ let totalBookmarks: number | undefined = undefined;
+ try {
+ const stats = await api.users.stats.query();
+ totalBookmarks = stats.numBookmarks;
+ } catch {
+ // ignore stats errors; progress will show without total
+ }
+
+ // 1) Rules
+ stepStart("Deleting rule engine rules");
+ const rulesStart = Date.now();
+ const rulesDeleted = await wipeRules(api, (deleted, total) => {
+ progressUpdate("Rules", deleted, total);
+ });
+ progressDone();
+ stepEndSuccess(
+ `${rulesDeleted} deleted in ${Math.round((Date.now() - rulesStart) / 1000)}s`,
+ );
+
+ // 2) Feeds
+ stepStart("Deleting feeds");
+ const feedsStart = Date.now();
+ const feedsDeleted = await wipeFeeds(api, (deleted, total) => {
+ progressUpdate("Feeds", deleted, total);
+ });
+ progressDone();
+ stepEndSuccess(
+ `${feedsDeleted} deleted in ${Math.round((Date.now() - feedsStart) / 1000)}s`,
+ );
+
+ // 3) Webhooks
+ stepStart("Deleting webhooks");
+ const webhooksStart = Date.now();
+ const webhooksDeleted = await wipeWebhooks(api, (deleted, total) => {
+ progressUpdate("Webhooks", deleted, total);
+ });
+ progressDone();
+ stepEndSuccess(
+ `${webhooksDeleted} deleted in ${Math.round((Date.now() - webhooksStart) / 1000)}s`,
+ );
+
+ // 4) Prompts
+ stepStart("Deleting AI prompts");
+ const promptsStart = Date.now();
+ const promptsDeleted = await wipePrompts(api, (deleted, total) => {
+ progressUpdate("Prompts", deleted, total);
+ });
+ progressDone();
+ stepEndSuccess(
+ `${promptsDeleted} deleted in ${Math.round((Date.now() - promptsStart) / 1000)}s`,
+ );
+
+ // 5) Bookmarks
+ stepStart("Deleting bookmarks");
+ const bmStart = Date.now();
+ const bookmarksDeleted = await wipeBookmarks(api, {
+ pageSize: Number(opts.batchSize) || 50,
+ total: totalBookmarks,
+ onProgress: (deleted, total) => {
+ progressUpdate("Bookmarks", deleted, total);
+ },
+ });
+ progressDone();
+ stepEndSuccess(
+ `${bookmarksDeleted} deleted in ${Math.round((Date.now() - bmStart) / 1000)}s`,
+ );
+
+ // 6) Lists
+ stepStart("Deleting lists");
+ const listsStart = Date.now();
+ const listsDeleted = await wipeLists(api, (deleted, total) => {
+ progressUpdate("Lists", deleted, total);
+ });
+ progressDone();
+ stepEndSuccess(
+ `${listsDeleted} deleted in ${Math.round((Date.now() - listsStart) / 1000)}s`,
+ );
+
+ // 7) Tags (unused)
+ stepStart("Deleting unused tags");
+ const tagsStart = Date.now();
+ const deletedTags = await wipeTags(api);
+ stepEndSuccess(
+ `${deletedTags} deleted in ${Math.round((Date.now() - tagsStart) / 1000)}s`,
+ );
+
+ printStatusMessage(true, "Wipe completed successfully");
+ } catch (error) {
+ stepEndFail();
+ printErrorMessageWithReason("Wipe failed", error as object);
+ }
+ });
+
+async function wipeRules(
+ api: ReturnType<typeof getAPIClient>,
+ onProgress?: (deleted: number, total: number) => void,
+) {
+ try {
+ const { rules } = await api.rules.list.query();
+ let deleted = 0;
+ for (const r of rules) {
+ try {
+ await api.rules.delete.mutate({ id: r.id });
+ deleted++;
+ onProgress?.(deleted, rules.length);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting rule "${r.id}"`,
+ e as object,
+ );
+ }
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting rules", error as object);
+ throw error;
+ }
+}
+
+async function wipeFeeds(
+ api: ReturnType<typeof getAPIClient>,
+ onProgress?: (deleted: number, total: number) => void,
+) {
+ try {
+ const { feeds } = await api.feeds.list.query();
+ let deleted = 0;
+ for (const f of feeds) {
+ try {
+ await api.feeds.delete.mutate({ feedId: f.id });
+ deleted++;
+ onProgress?.(deleted, feeds.length);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting feed "${f.id}"`,
+ e as object,
+ );
+ }
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting feeds", error as object);
+ throw error;
+ }
+}
+
+async function wipeWebhooks(
+ api: ReturnType<typeof getAPIClient>,
+ onProgress?: (deleted: number, total: number) => void,
+) {
+ try {
+ const { webhooks } = await api.webhooks.list.query();
+ let deleted = 0;
+ for (const w of webhooks) {
+ try {
+ await api.webhooks.delete.mutate({ webhookId: w.id });
+ deleted++;
+ onProgress?.(deleted, webhooks.length);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting webhook "${w.id}"`,
+ e as object,
+ );
+ }
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting webhooks", error as object);
+ throw error;
+ }
+}
+
+async function wipePrompts(
+ api: ReturnType<typeof getAPIClient>,
+ onProgress?: (deleted: number, total: number) => void,
+) {
+ try {
+ const prompts = await api.prompts.list.query();
+ let deleted = 0;
+ for (const p of prompts) {
+ try {
+ await api.prompts.delete.mutate({ promptId: p.id });
+ deleted++;
+ onProgress?.(deleted, prompts.length);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting prompt "${p.id}"`,
+ e as object,
+ );
+ }
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting AI prompts", error as object);
+ throw error;
+ }
+}
+
+async function wipeBookmarks(
+ api: ReturnType<typeof getAPIClient>,
+ opts: {
+ pageSize: number;
+ total?: number;
+ onProgress?: (deleted: number, total?: number) => void;
+ },
+) {
+ try {
+ let cursor: ZCursor | null | undefined = undefined;
+ let deleted = 0;
+ while (true) {
+ const resp = await api.bookmarks.getBookmarks.query({
+ limit: opts.pageSize,
+ cursor,
+ useCursorV2: true,
+ });
+ for (const b of resp.bookmarks) {
+ try {
+ await api.bookmarks.deleteBookmark.mutate({ bookmarkId: b.id });
+ deleted++;
+ opts.onProgress?.(deleted, opts.total);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting bookmark "${b.id}"`,
+ e as object,
+ );
+ }
+ }
+ cursor = resp.nextCursor;
+ if (!cursor) break;
+ opts.onProgress?.(deleted, opts.total);
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting bookmarks", error as object);
+ throw error;
+ }
+}
+
+async function wipeLists(
+ api: ReturnType<typeof getAPIClient>,
+ onProgress?: (deleted: number, total: number) => void,
+) {
+ try {
+ const { lists } = await api.lists.list.query();
+ // Delete child lists first (deepest first)
+ const depthCache = new Map<string, number>();
+ const byId = new Map(lists.map((l) => [l.id, l]));
+ const getDepth = (id: string): number => {
+ const cached = depthCache.get(id);
+ if (cached != null) return cached;
+ let d = 0;
+ let cur = byId.get(id);
+ const visited = new Set<string>();
+ while (cur?.parentId) {
+ if (visited.has(cur.parentId)) break; // cycle guard
+ visited.add(cur.parentId);
+ d++;
+ cur = byId.get(cur.parentId);
+ }
+ depthCache.set(id, d);
+ return d;
+ };
+ const ordered = lists
+ .slice()
+ .sort((a, b) => getDepth(b.id) - getDepth(a.id));
+ let deleted = 0;
+ for (const l of ordered) {
+ try {
+ await api.lists.delete.mutate({ listId: l.id });
+ deleted++;
+ onProgress?.(deleted, lists.length);
+ } catch (e) {
+ printErrorMessageWithReason(
+ `Failed deleting list "${l.id}"`,
+ e as object,
+ );
+ }
+ }
+ return deleted;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting lists", error as object);
+ throw error;
+ }
+}
+
+async function wipeTags(api: ReturnType<typeof getAPIClient>) {
+ try {
+ const res = await api.tags.deleteUnused.mutate();
+ return res.deletedTags;
+ } catch (error) {
+ printErrorMessageWithReason("Failed deleting tags", error as object);
+ throw error;
+ }
+}
diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts
index 7c948c47..c698169f 100644
--- a/apps/cli/src/index.ts
+++ b/apps/cli/src/index.ts
@@ -3,6 +3,7 @@ import { listsCmd } from "@/commands/lists";
import { migrateCmd } from "@/commands/migrate";
import { tagsCmd } from "@/commands/tags";
import { whoamiCmd } from "@/commands/whoami";
+import { wipeCmd } from "@/commands/wipe";
import { setGlobalOptions } from "@/lib/globals";
import { Command, Option } from "@commander-js/extra-typings";
@@ -34,6 +35,7 @@ program.addCommand(listsCmd);
program.addCommand(tagsCmd);
program.addCommand(whoamiCmd);
program.addCommand(migrateCmd);
+program.addCommand(wipeCmd);
setGlobalOptions(program.opts());