#!/usr/bin/env tsx /** * Test Data Cleanup Utility * * Interactive CLI for managing test organizations, users, and tenant data. * Supports listing, selective deletion, full purge, and re-seeding. * * Usage: * cd scripts * npx tsx cleanup-test-data.ts [options] * * Commands: * list Show all organizations and users * delete-org Delete an organization (drops tenant schema + shared data) * delete-user Delete a user (cascades through all related tables) * purge-all Remove ALL orgs/users except platform owner * reseed Purge all, then re-run db/seed/seed.sql * * Options: * --dry-run Show what would be deleted without executing * --force Skip confirmation prompts * * Environment: * DATABASE_URL - PostgreSQL connection string (reads from ../.env) */ import * as dotenv from 'dotenv'; import { resolve } from 'path'; import { readFileSync } from 'fs'; import { Pool } from 'pg'; import * as readline from 'readline'; // ── Load environment ──────────────────────────────────────────────────────── dotenv.config({ path: resolve(__dirname, '..', '.env') }); const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error(red('✗ DATABASE_URL not set. Check your .env file.')); process.exit(1); } // ── CLI colors ────────────────────────────────────────────────────────────── function red(s: string): string { return `\x1b[31m${s}\x1b[0m`; } function green(s: string): string { return `\x1b[32m${s}\x1b[0m`; } function yellow(s: string): string { return `\x1b[33m${s}\x1b[0m`; } function cyan(s: string): string { return `\x1b[36m${s}\x1b[0m`; } function bold(s: string): string { return `\x1b[1m${s}\x1b[0m`; } function dim(s: string): string { return `\x1b[2m${s}\x1b[0m`; } // ── CLI argument parsing ──────────────────────────────────────────────────── const args = process.argv.slice(2); const command = args.find(a => !a.startsWith('--')) || ''; const target = args.filter(a => !a.startsWith('--')).slice(1).join(' '); const dryRun = args.includes('--dry-run'); const force = args.includes('--force'); // ── Helpers ───────────────────────────────────────────────────────────────── function isUUID(s: string): boolean { return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); } function padRight(s: string, len: number): string { return s.length >= len ? s.substring(0, len) : s + ' '.repeat(len - s.length); } function truncate(s: string, len: number): string { return s.length > len ? s.substring(0, len - 1) + '…' : s; } function formatDate(d: Date | string | null): string { if (!d) return '—'; const date = typeof d === 'string' ? new Date(d) : d; return date.toISOString().split('T')[0]; } async function confirm(prompt: string): Promise { if (force) return true; if (dryRun) return false; const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); return new Promise((resolve) => { rl.question(`${prompt} [y/N]: `, (answer) => { rl.close(); resolve(answer.trim().toLowerCase() === 'y'); }); }); } function logDryRun(sql: string): void { console.log(dim(` [DRY RUN] ${sql}`)); } // ── Database pool ─────────────────────────────────────────────────────────── const pool = new Pool({ connectionString: DATABASE_URL }); async function query(sql: string, params?: any[]): Promise { const result = await pool.query(sql, params); return result.rows; } // ── List command ──────────────────────────────────────────────────────────── async function listAll(): Promise { console.log(bold('\n📋 Organizations\n')); const orgs = await query(` SELECT o.id, o.name, o.schema_name, o.status, o.plan_level, o.billing_interval, o.collection_method, o.stripe_customer_id, o.stripe_subscription_id, o.trial_ends_at, o.created_at, COUNT(uo.id) AS user_count FROM shared.organizations o LEFT JOIN shared.user_organizations uo ON uo.organization_id = o.id GROUP BY o.id ORDER BY o.created_at `); if (orgs.length === 0) { console.log(dim(' No organizations found.\n')); } else { // Header console.log( ' ' + padRight('Name', 30) + padRight('Status', 12) + padRight('Plan', 16) + padRight('Billing', 10) + padRight('Users', 7) + padRight('Stripe Customer', 22) + 'Created' ); console.log(' ' + '─'.repeat(110)); for (const o of orgs) { const statusColor = o.status === 'active' ? green : o.status === 'trial' ? cyan : o.status === 'past_due' ? yellow : red; console.log( ' ' + padRight(truncate(o.name, 28), 30) + padRight(statusColor(o.status), 12 + 9) + // +9 for ANSI escape codes padRight(`${o.plan_level}/${o.billing_interval || 'month'}`, 16) + padRight(String(o.user_count), 7) + padRight(o.stripe_customer_id ? truncate(o.stripe_customer_id, 20) : '—', 22) + formatDate(o.created_at) ); } console.log(dim(`\n ${orgs.length} organization(s) total`)); // Show IDs for reference console.log(dim('\n IDs:')); for (const o of orgs) { console.log(dim(` ${o.name}: ${o.id}`)); console.log(dim(` schema: ${o.schema_name}`)); } } console.log(bold('\n👤 Users\n')); const users = await query(` SELECT u.id, u.email, u.first_name, u.last_name, u.is_superadmin, u.is_platform_owner, u.last_login_at, u.created_at, COALESCE( STRING_AGG( o.name || ' (' || uo.role || ')', ', ' ), '—' ) AS memberships, COUNT(uo.id) AS org_count FROM shared.users u LEFT JOIN shared.user_organizations uo ON uo.user_id = u.id LEFT JOIN shared.organizations o ON o.id = uo.organization_id GROUP BY u.id ORDER BY u.created_at `); if (users.length === 0) { console.log(dim(' No users found.\n')); } else { // Header console.log( ' ' + padRight('Email', 35) + padRight('Name', 25) + padRight('Flags', 18) + padRight('Orgs', 6) + 'Created' ); console.log(' ' + '─'.repeat(100)); for (const u of users) { const flags: string[] = []; if (u.is_platform_owner) flags.push(cyan('owner')); if (u.is_superadmin) flags.push(yellow('super')); const name = [u.first_name, u.last_name].filter(Boolean).join(' ') || '—'; console.log( ' ' + padRight(truncate(u.email, 33), 35) + padRight(truncate(name, 23), 25) + padRight(flags.length ? flags.join(', ') : '—', 18 + (flags.length * 9)) + padRight(String(u.org_count), 6) + formatDate(u.created_at) ); } console.log(dim(`\n ${users.length} user(s) total`)); // Show memberships console.log(dim('\n Memberships:')); for (const u of users) { console.log(dim(` ${u.email}: ${u.memberships}`)); } } // Tenant schemas console.log(bold('\n🗄️ Tenant Schemas\n')); const schemas = await query(` SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name `); if (schemas.length === 0) { console.log(dim(' No tenant schemas found.\n')); } else { for (const s of schemas) { console.log(` • ${s.schema_name}`); } console.log(dim(`\n ${schemas.length} tenant schema(s) total\n`)); } } // ── Delete organization ───────────────────────────────────────────────────── async function deleteOrg(identifier: string): Promise { if (!identifier) { console.error(red('✗ Please provide an organization name or ID.')); console.log(' Usage: npx tsx cleanup-test-data.ts delete-org '); process.exit(1); } // Look up org const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(name) = LOWER($1)'; const orgs = await query( `SELECT id, name, schema_name, status, stripe_customer_id, stripe_subscription_id FROM shared.organizations WHERE ${whereClause}`, [identifier] ); if (orgs.length === 0) { console.error(red(`✗ Organization not found: ${identifier}`)); process.exit(1); } const org = orgs[0]; // Show what will be deleted console.log(bold(`\n🏢 Delete Organization: ${org.name}\n`)); console.log(` ID: ${org.id}`); console.log(` Schema: ${org.schema_name}`); console.log(` Status: ${org.status}`); if (org.stripe_customer_id) { console.log(yellow(`\n ⚠ Stripe Customer: ${org.stripe_customer_id}`)); console.log(yellow(` You should manually delete/archive this customer in the Stripe Dashboard.`)); } if (org.stripe_subscription_id) { console.log(yellow(` ⚠ Stripe Subscription: ${org.stripe_subscription_id}`)); console.log(yellow(` You should manually cancel this subscription in the Stripe Dashboard.`)); } // Count related data const userCount = (await query( 'SELECT COUNT(*) as cnt FROM shared.user_organizations WHERE organization_id = $1', [org.id] ))[0].cnt; const inviteCount = (await query( 'SELECT COUNT(*) as cnt FROM shared.invitations WHERE organization_id = $1', [org.id] ))[0].cnt; // Check if tenant schema exists const schemaExists = (await query( `SELECT COUNT(*) as cnt FROM information_schema.schemata WHERE schema_name = $1`, [org.schema_name] ))[0].cnt > 0; console.log(`\n Will delete:`); console.log(` • Organization record from shared.organizations`); console.log(` • ${userCount} user-organization membership(s) (users themselves are preserved)`); console.log(` • ${inviteCount} invitation(s)`); if (schemaExists) { console.log(red(` • DROP SCHEMA ${org.schema_name} CASCADE (all tenant financial data)`)); } else { console.log(dim(` • Schema ${org.schema_name} does not exist (skip)`)); } console.log(` • Related rows in: onboarding_progress, stripe_events, email_log`); if (dryRun) { console.log(yellow('\n [DRY RUN] No changes made.\n')); logDryRun(`DROP SCHEMA IF EXISTS ${org.schema_name} CASCADE`); logDryRun(`DELETE FROM shared.onboarding_progress WHERE organization_id = '${org.id}'`); logDryRun(`DELETE FROM shared.stripe_events WHERE ... (related to org)`); logDryRun(`DELETE FROM shared.organizations WHERE id = '${org.id}'`); return; } const confirmed = await confirm(red(`\n This is destructive and cannot be undone. Proceed?`)); if (!confirmed) { console.log(dim(' Aborted.\n')); return; } // Execute deletion const client = await pool.connect(); try { await client.query('BEGIN'); // 1. Drop tenant schema if (schemaExists) { console.log(` Dropping schema ${org.schema_name}...`); await client.query(`DROP SCHEMA IF EXISTS "${org.schema_name}" CASCADE`); } // 2. Clean up shared tables with org FK await client.query('DELETE FROM shared.onboarding_progress WHERE organization_id = $1', [org.id]); await client.query('DELETE FROM shared.invitations WHERE organization_id = $1', [org.id]); // 3. Delete organization (cascades to user_organizations, invite_tokens) await client.query('DELETE FROM shared.organizations WHERE id = $1', [org.id]); await client.query('COMMIT'); console.log(green(`\n ✓ Organization "${org.name}" and schema "${org.schema_name}" deleted successfully.\n`)); } catch (err) { await client.query('ROLLBACK'); console.error(red(`\n ✗ Error deleting organization: ${(err as Error).message}\n`)); throw err; } finally { client.release(); } } // ── Delete user ───────────────────────────────────────────────────────────── async function deleteUser(identifier: string): Promise { if (!identifier) { console.error(red('✗ Please provide a user email or ID.')); console.log(' Usage: npx tsx cleanup-test-data.ts delete-user '); process.exit(1); } const whereClause = isUUID(identifier) ? 'id = $1' : 'LOWER(email) = LOWER($1)'; const users = await query( `SELECT id, email, first_name, last_name, is_superadmin, is_platform_owner FROM shared.users WHERE ${whereClause}`, [identifier] ); if (users.length === 0) { console.error(red(`✗ User not found: ${identifier}`)); process.exit(1); } const user = users[0]; const name = [user.first_name, user.last_name].filter(Boolean).join(' ') || '(no name)'; // Platform owner protection if (user.is_platform_owner) { console.error(red(`\n ✗ Cannot delete platform owner: ${user.email}`)); console.error(red(' The platform owner account is protected and cannot be removed.\n')); process.exit(1); } console.log(bold(`\n👤 Delete User: ${user.email}\n`)); console.log(` ID: ${user.id}`); console.log(` Name: ${name}`); if (user.is_superadmin) { console.log(yellow(' ⚠ This user is a SUPERADMIN')); } // Count related data const memberships = await query( `SELECT o.name, uo.role FROM shared.user_organizations uo JOIN shared.organizations o ON o.id = uo.organization_id WHERE uo.user_id = $1`, [user.id] ); const tokenCounts = { refresh: (await query('SELECT COUNT(*) as cnt FROM shared.refresh_tokens WHERE user_id = $1', [user.id]))[0].cnt, passkeys: (await query('SELECT COUNT(*) as cnt FROM shared.user_passkeys WHERE user_id = $1', [user.id]))[0].cnt, loginHistory: (await query('SELECT COUNT(*) as cnt FROM shared.login_history WHERE user_id = $1', [user.id]))[0].cnt, }; console.log(`\n Will delete:`); console.log(` • User record from shared.users`); console.log(` • ${memberships.length} org membership(s):`); for (const m of memberships) { console.log(` – ${m.name} (${m.role})`); } console.log(` • ${tokenCounts.refresh} refresh token(s)`); console.log(` • ${tokenCounts.passkeys} passkey(s)`); console.log(` • ${tokenCounts.loginHistory} login history record(s)`); console.log(` • Related: password_reset_tokens, invite_tokens (cascade)`); if (dryRun) { console.log(yellow('\n [DRY RUN] No changes made.\n')); logDryRun(`DELETE FROM shared.users WHERE id = '${user.id}'`); return; } const confirmMsg = user.is_superadmin ? red(`\n ⚠ This is a SUPERADMIN account. Are you SURE you want to delete it?`) : red(`\n This is destructive and cannot be undone. Proceed?`); const confirmed = await confirm(confirmMsg); if (!confirmed) { console.log(dim(' Aborted.\n')); return; } // Execute deletion (cascade handles related tables) await query('DELETE FROM shared.users WHERE id = $1', [user.id]); console.log(green(`\n ✓ User "${user.email}" deleted successfully.\n`)); } // ── Purge all ─────────────────────────────────────────────────────────────── async function purgeAll(): Promise { console.log(bold('\n🔥 Purge All Test Data\n')); // Gather current state const orgs = await query( `SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id FROM shared.organizations ORDER BY name` ); const userCount = (await query( 'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false' ))[0].cnt; const platformOwner = (await query( 'SELECT email FROM shared.users WHERE is_platform_owner = true' )); const schemas = await query( `SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name` ); // Stripe warnings const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id); console.log(` This will:`); console.log(red(` • Drop ${schemas.length} tenant schema(s):`)); for (const s of schemas) { console.log(red(` – ${s.schema_name}`)); } console.log(red(` • Delete ${orgs.length} organization(s):`)); for (const o of orgs) { console.log(red(` – ${o.name}`)); } console.log(red(` • Delete ${userCount} non-owner user(s)`)); console.log(` • Truncate: user_organizations, invitations, refresh_tokens,`); console.log(` password_reset_tokens, invite_tokens, user_passkeys,`); console.log(` login_history, ai_recommendation_log, stripe_events,`); console.log(` onboarding_progress, email_log`); console.log(green(` • Preserve: platform owner (${platformOwner.length ? platformOwner[0].email : 'none found'})`)); console.log(green(` • Preserve: cd_rates (market data)`)); if (stripeOrgs.length > 0) { console.log(yellow('\n ⚠ Stripe data that should be cleaned up manually:')); for (const o of stripeOrgs) { if (o.stripe_customer_id) { console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`)); } if (o.stripe_subscription_id) { console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`)); } } } if (dryRun) { console.log(yellow('\n [DRY RUN] No changes made.\n')); for (const s of schemas) { logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`); } logDryRun('TRUNCATE shared.user_organizations, shared.invitations, ...'); logDryRun('DELETE FROM shared.organizations'); logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false"); return; } const confirmed = await confirm(red(`\n ⚠ THIS WILL DESTROY ALL DATA. Are you absolutely sure?`)); if (!confirmed) { console.log(dim(' Aborted.\n')); return; } const client = await pool.connect(); try { await client.query('BEGIN'); // 1. Drop all tenant schemas for (const s of schemas) { console.log(` Dropping schema ${s.schema_name}...`); await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`); } // 2. Truncate shared junction/log tables (order matters for FK constraints) console.log(' Truncating shared tables...'); // Tables with FK to users AND organizations — truncate first await client.query('DELETE FROM shared.user_organizations'); await client.query('DELETE FROM shared.invitations'); await client.query('DELETE FROM shared.invite_tokens'); await client.query('DELETE FROM shared.onboarding_progress'); // Tables with FK to users only await client.query('DELETE FROM shared.refresh_tokens'); await client.query('DELETE FROM shared.password_reset_tokens'); await client.query('DELETE FROM shared.user_passkeys'); await client.query('DELETE FROM shared.login_history'); // Tables with FK to organizations (ON DELETE SET NULL) await client.query('DELETE FROM shared.ai_recommendation_log'); await client.query('DELETE FROM shared.stripe_events'); await client.query('DELETE FROM shared.email_log'); // 3. Delete organizations console.log(' Deleting organizations...'); await client.query('DELETE FROM shared.organizations'); // 4. Delete non-owner users console.log(' Deleting non-owner users...'); await client.query('DELETE FROM shared.users WHERE is_platform_owner = false'); await client.query('COMMIT'); console.log(green(`\n ✓ Purge complete.`)); console.log(green(` Dropped ${schemas.length} schema(s), deleted ${orgs.length} org(s), deleted ${userCount} user(s).`)); if (platformOwner.length) { console.log(green(` Platform owner preserved: ${platformOwner[0].email}\n`)); } } catch (err) { await client.query('ROLLBACK'); console.error(red(`\n ✗ Error during purge: ${(err as Error).message}\n`)); throw err; } finally { client.release(); } } // ── Reseed ────────────────────────────────────────────────────────────────── async function reseed(): Promise { console.log(bold('\n🌱 Purge All + Re-Seed\n')); console.log(' This will purge all test data and then run db/seed/seed.sql'); console.log(' to restore the default test environment.\n'); if (!dryRun && !force) { const confirmed = await confirm(red(' This will destroy all data and re-seed. Proceed?')); if (!confirmed) { console.log(dim(' Aborted.\n')); return; } // Set force for the inner purge to avoid double-prompting (global as any).__forceOverride = true; } // Run purge const origForce = force; try { // Temporarily force purge to skip its own confirmation if (!dryRun) { Object.defineProperty(global, '__forceOverride', { value: true, writable: true, configurable: true }); } await purgeAllInternal(); } finally { delete (global as any).__forceOverride; } if (dryRun) { logDryRun('Execute db/seed/seed.sql'); console.log(yellow('\n [DRY RUN] No changes made.\n')); return; } // Run seed SQL console.log('\n Running seed script...'); const seedPath = resolve(__dirname, '..', 'db', 'seed', 'seed.sql'); let seedSql: string; try { seedSql = readFileSync(seedPath, 'utf-8'); } catch (err) { console.error(red(` ✗ Could not read seed file: ${seedPath}`)); console.error(red(` ${(err as Error).message}\n`)); process.exit(1); } const client = await pool.connect(); try { await client.query(seedSql); console.log(green(`\n ✓ Re-seed complete. Database restored to seed state.\n`)); } catch (err) { console.error(red(`\n ✗ Error running seed: ${(err as Error).message}\n`)); throw err; } finally { client.release(); } } /** * Internal purge that respects __forceOverride to skip confirmation * when called from reseed(). */ async function purgeAllInternal(): Promise { const orgs = await query( `SELECT id, name, schema_name, stripe_customer_id, stripe_subscription_id FROM shared.organizations ORDER BY name` ); const userCount = (await query( 'SELECT COUNT(*) as cnt FROM shared.users WHERE is_platform_owner = false' ))[0].cnt; const platformOwner = await query( 'SELECT email FROM shared.users WHERE is_platform_owner = true' ); const schemas = await query( `SELECT schema_name FROM information_schema.schemata WHERE schema_name LIKE 'tenant_%' ORDER BY schema_name` ); const stripeOrgs = orgs.filter((o: any) => o.stripe_customer_id || o.stripe_subscription_id); if (stripeOrgs.length > 0) { console.log(yellow(' ⚠ Stripe data that should be cleaned up manually:')); for (const o of stripeOrgs) { if (o.stripe_customer_id) console.log(yellow(` Customer: ${o.stripe_customer_id} (${o.name})`)); if (o.stripe_subscription_id) console.log(yellow(` Subscription: ${o.stripe_subscription_id} (${o.name})`)); } } if (dryRun) { for (const s of schemas) { logDryRun(`DROP SCHEMA "${s.schema_name}" CASCADE`); } logDryRun('DELETE FROM shared tables...'); logDryRun('DELETE FROM shared.organizations'); logDryRun("DELETE FROM shared.users WHERE is_platform_owner = false"); return; } const client = await pool.connect(); try { await client.query('BEGIN'); for (const s of schemas) { console.log(` Dropping schema ${s.schema_name}...`); await client.query(`DROP SCHEMA IF EXISTS "${s.schema_name}" CASCADE`); } console.log(' Truncating shared tables...'); await client.query('DELETE FROM shared.user_organizations'); await client.query('DELETE FROM shared.invitations'); await client.query('DELETE FROM shared.invite_tokens'); await client.query('DELETE FROM shared.onboarding_progress'); await client.query('DELETE FROM shared.refresh_tokens'); await client.query('DELETE FROM shared.password_reset_tokens'); await client.query('DELETE FROM shared.user_passkeys'); await client.query('DELETE FROM shared.login_history'); await client.query('DELETE FROM shared.ai_recommendation_log'); await client.query('DELETE FROM shared.stripe_events'); await client.query('DELETE FROM shared.email_log'); console.log(' Deleting organizations...'); await client.query('DELETE FROM shared.organizations'); console.log(' Deleting non-owner users...'); await client.query('DELETE FROM shared.users WHERE is_platform_owner = false'); await client.query('COMMIT'); console.log(green(` ✓ Purged ${schemas.length} schema(s), ${orgs.length} org(s), ${userCount} user(s).`)); if (platformOwner.length) { console.log(green(` Platform owner preserved: ${platformOwner[0].email}`)); } } catch (err) { await client.query('ROLLBACK'); console.error(red(` ✗ Error during purge: ${(err as Error).message}`)); throw err; } finally { client.release(); } } // ── Help ──────────────────────────────────────────────────────────────────── function showHelp(): void { console.log(` ${bold('HOA LedgerIQ — Test Data Cleanup Utility')} ${bold('Usage:')} npx tsx cleanup-test-data.ts [target] [options] ${bold('Commands:')} ${cyan('list')} Show all organizations, users, and tenant schemas ${cyan('delete-org')} Delete an organization and its tenant schema ${cyan('delete-user')} Delete a user and all related data ${cyan('purge-all')} Remove ALL data except the platform owner ${cyan('reseed')} Purge all, then re-run db/seed/seed.sql ${bold('Options:')} ${dim('--dry-run')} Show what would happen without making changes ${dim('--force')} Skip confirmation prompts ${bold('Examples:')} npx tsx cleanup-test-data.ts list npx tsx cleanup-test-data.ts delete-org "Sunrise Valley HOA" npx tsx cleanup-test-data.ts delete-org 550e8400-e29b-41d4-a716-446655440000 npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org npx tsx cleanup-test-data.ts delete-user admin@sunrisevalley.org --dry-run npx tsx cleanup-test-data.ts purge-all --dry-run npx tsx cleanup-test-data.ts reseed --force ${bold('Safety:')} • Platform owner account (is_platform_owner=true) is ${green('never deleted')} • Superadmin deletions require extra confirmation • Stripe customer/subscription IDs are shown as warnings for manual cleanup • cd_rates market data is ${green('always preserved')} `); } // ── Main ──────────────────────────────────────────────────────────────────── async function main(): Promise { if (dryRun) { console.log(yellow('\n ── DRY RUN MODE ── No changes will be made ──\n')); } try { switch (command) { case 'list': await listAll(); break; case 'delete-org': await deleteOrg(target); break; case 'delete-user': await deleteUser(target); break; case 'purge-all': await purgeAll(); break; case 'reseed': await reseed(); break; case 'help': case '--help': case '-h': showHelp(); break; default: if (command) { console.error(red(`\n ✗ Unknown command: ${command}\n`)); } showHelp(); process.exit(command ? 1 : 0); } } catch (err) { console.error(red(`\nFatal error: ${(err as Error).message}`)); process.exit(1); } finally { await pool.end(); } } main();