diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-09-14 13:01:19 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-09-14 13:01:19 +0000 |
| commit | bc0e7461503c6b6d9ef7bec3bf8607f5e33a896b (patch) | |
| tree | b39f115346935d559e257a2e9b578087afd4a4b5 /apps/cli/src | |
| parent | 783f72cb91b436e8ee6d7349da4cf72dc3219aa1 (diff) | |
| download | karakeep-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.ts | 386 | ||||
| -rw-r--r-- | apps/cli/src/index.ts | 2 |
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()); |
