diff --git a/scripts/reset-user-data.ts b/scripts/reset-user-data.ts new file mode 100644 index 00000000..8d6771a5 --- /dev/null +++ b/scripts/reset-user-data.ts @@ -0,0 +1,175 @@ +/** + * Reset all user data while keeping auth credentials. + * + * Deletes: + * - DB: user_settings, user_state, user_integrations, dock_configs, + * chat_sessions (cascades chat_messages), chat_groups, + * workspaces, screens, projects, + * tasks, skills, processes, tools, extensions (+ item_chats), + * task_logs, queue_jobs, terminal_containers + * - Filesystem: entire $DATA_PATH// directory + * (home, settings, state, workspaces, chat_sessions, emails.db, + * Gmail, skills, tools, tasks, processes, extensions, logs, cache, etc.) + * - Queue job files: $DATA_PATH/queue/jobs/*.json owned by user + * - Terminal containers map: removes user entry from terminal-containers.json + * + * Preserves: + * - users table row (account, password, role, status) + * - passkeys table rows + * - passkey_challenges, token_blacklist + * + * Usage: bun run scripts/reset-user-data.ts + * bun run scripts/reset-user-data.ts --yes (skip confirmation) + */ + +import { join } from 'node:path'; +import { rm, readdir, unlink } from 'node:fs/promises'; +import { db } from 'officerdb/db'; +import { users } from 'officerdb/schema'; +import { eq, sql } from 'drizzle-orm'; + +const DATA_PATH = process.env.DATA_PATH ?? join(process.cwd(), 'data'); + +const email = process.argv[2]; +const skipConfirm = process.argv.includes('--yes'); + +if (!email) { + console.error('Usage: bun run scripts/reset-user-data.ts [--yes]'); + process.exit(1); +} + +// ── Resolve user ── + +const [user] = await db.select({ id: users.id, email: users.email }).from(users).where(eq(users.email, email)); + +if (!user) { + console.error(`User not found: ${email}`); + process.exit(1); +} + +console.log(`\nUser: ${user.email} (id: ${user.id})`); +console.log(`Data dir: ${join(DATA_PATH, email)}`); +console.log('\nThis will delete ALL user data (settings, chats, workspaces, emails, home dir, etc.)'); +console.log('Auth credentials (account, passkeys) will be preserved.\n'); + +if (!skipConfirm) { + process.stdout.write('Continue? [y/N] '); + const response = await new Promise((resolve) => { + process.stdin.once('data', (data) => resolve(data.toString().trim())); + }); + if (response.toLowerCase() !== 'y') { + console.log('Aborted.'); + process.exit(0); + } +} + +const userId = user.id; + +// ── Database cleanup ── +// All these tables have ON DELETE CASCADE from users, but we don't want to delete the user. +// Delete explicitly by user_id. + +console.log('\n── Database ──'); + +const tables = [ + 'user_settings', + 'user_state', + 'user_integrations', + 'dock_configs', + 'chat_sessions', // cascades chat_messages + 'chat_groups', + 'workspaces', + 'screens', + 'projects', + 'task_logs', + 'queue_jobs', + 'terminal_containers', +]; + +for (const table of tables) { + const result = await db.execute(sql.raw(`DELETE FROM ${table} WHERE user_id = ${userId}`)); + const count = result.length ?? 0; + console.log(` ${table}: ${count} rows deleted`); +} + +// Agent items: tasks, skills, processes, tools, extensions +// These have nullable user_id — delete only rows belonging to this user +const agentTables = ['tasks', 'skills', 'processes', 'tools', 'extensions']; + +for (const table of agentTables) { + // First collect item IDs to clean up item_chats + const items = await db.execute(sql.raw(`SELECT id FROM ${table} WHERE user_id = ${userId}`)); + if (items.length > 0) { + const ids = items.map((r: Record) => r.id).join(','); + const chatResult = await db.execute(sql.raw(`DELETE FROM item_chats WHERE item_type = '${table}' AND item_id IN (${ids})`)); + console.log(` item_chats (${table}): ${chatResult.length ?? 0} rows deleted`); + } + const result = await db.execute(sql.raw(`DELETE FROM ${table} WHERE user_id = ${userId}`)); + console.log(` ${table}: ${result.length ?? 0} rows deleted`); +} + +// ── Queue job files ── + +console.log('\n── Queue job files ──'); +const queueDir = join(DATA_PATH, 'queue', 'jobs'); +try { + const entries = await readdir(queueDir); + let deleted = 0; + for (const entry of entries) { + if (!entry.endsWith('.json')) continue; + try { + const file = Bun.file(join(queueDir, entry)); + const job = await file.json(); + if (job.userId === email) { + await unlink(join(queueDir, entry)); + deleted++; + } + } catch { + // skip unreadable files + } + } + console.log(` ${deleted} job files deleted`); +} catch { + console.log(' queue dir not found, skipping'); +} + +// ── Terminal containers map ── + +console.log('\n── Terminal containers ──'); +const containerMapPath = join(DATA_PATH, 'terminal-containers.json'); +try { + const file = Bun.file(containerMapPath); + if (await file.exists()) { + const map = await file.json(); + let changed = false; + for (const key of Object.keys(map)) { + if (key === email || map[key]?.email === email) { + delete map[key]; + changed = true; + } + } + if (changed) { + await Bun.write(containerMapPath, JSON.stringify(map, null, 2)); + console.log(' removed from terminal-containers.json'); + } else { + console.log(' no entry found'); + } + } +} catch { + console.log(' terminal-containers.json not found, skipping'); +} + +// ── Filesystem ── + +console.log('\n── Filesystem ──'); +const userDir = join(DATA_PATH, email); +try { + await rm(userDir, { recursive: true, force: true }); + console.log(` removed ${userDir}`); +} catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + console.log(` failed to remove ${userDir}: ${msg}`); +} + +console.log('\nDone. User auth preserved, all data wiped.'); +process.exit(0);