From 6c438eab32b579d4527f9278b870d0d45211e9ec Mon Sep 17 00:00:00 2001 From: Matt Batchelder Date: Fri, 24 Jul 2026 07:02:11 -0400 Subject: [PATCH] feat: add admin CLI for project management - User management (list, create, delete, reset-password) - Data CRUD for all 32 collections - Import/export with JSON support - Worker/queue management (status, jobs, retry, trigger) - Webhook management (list, test, deliveries, retry) - Agent management (list, CRUD, tasks, trigger, activity) - Health diagnostics (check, errors, stats) - JSON output mode (--json flag) - Run via: npm run cli -- --- package.json | 9 +- packages/cli/commands/agent.ts | 298 ++++++++++++++++++++++++++ packages/cli/commands/data.ts | 349 +++++++++++++++++++++++++++++++ packages/cli/commands/health.ts | 159 ++++++++++++++ packages/cli/commands/user.ts | 149 +++++++++++++ packages/cli/commands/webhook.ts | 228 ++++++++++++++++++++ packages/cli/commands/worker.ts | 180 ++++++++++++++++ packages/cli/index.ts | 67 ++++++ packages/cli/lib/args.ts | 72 +++++++ packages/cli/lib/db.ts | 47 +++++ packages/cli/lib/output.ts | 51 +++++ packages/cli/package.json | 21 ++ packages/cli/tsconfig.json | 17 ++ 13 files changed, 1644 insertions(+), 3 deletions(-) create mode 100644 packages/cli/commands/agent.ts create mode 100644 packages/cli/commands/data.ts create mode 100644 packages/cli/commands/health.ts create mode 100644 packages/cli/commands/user.ts create mode 100644 packages/cli/commands/webhook.ts create mode 100644 packages/cli/commands/worker.ts create mode 100755 packages/cli/index.ts create mode 100644 packages/cli/lib/args.ts create mode 100644 packages/cli/lib/db.ts create mode 100644 packages/cli/lib/output.ts create mode 100644 packages/cli/package.json create mode 100644 packages/cli/tsconfig.json diff --git a/package.json b/package.json index b137b1b..5fb6e04 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "workspaces": [ "apps/*", "packages/*", - "worker" + "worker", + "packages/cli" ], "scripts": { "dev": "turbo dev", @@ -15,11 +16,13 @@ "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:report": "playwright show-report", - "db:generate": "turbo db:generate", - "typecheck": "turbo typecheck" + "db:generate": "drizzle-kit generate", + "typecheck": "turbo typecheck", + "cli": "tsx packages/cli/index.ts" }, "devDependencies": { "@playwright/test": "^1.61.1", + "drizzle-kit": "^0.31.10", "tsx": "^4.23.1", "turbo": "^2.5.0", "typescript": "^5.9.3" diff --git a/packages/cli/commands/agent.ts b/packages/cli/commands/agent.ts new file mode 100644 index 0000000..cba6ecf --- /dev/null +++ b/packages/cli/commands/agent.ts @@ -0,0 +1,298 @@ +import { parseArgs, getFlag, getFlagNumber, hasFlag } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError, printHeader } from '../lib/output.js'; +import { db, records, eq, sql } from '../lib/db.js'; + +async function listAgents(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const json = flags.json === true; + + const rows = await db.select().from(records) + .where(eq(records.collection, 'agents')) + .limit(limit); + + const agents = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + name: data.name || '', + webhook_url: String(data.webhook_url || '').slice(0, 40), + active: data.active, + last_activity: data.last_activity_at ? String(data.last_activity_at).slice(0, 19) : 'never', + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(agents); + } else { + printTable(agents, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'name', label: 'Name', width: 20 }, + { key: 'webhook_url', label: 'Webhook URL', width: 40 }, + { key: 'active', label: 'Active', width: 8 }, + { key: 'last_activity', label: 'Last Activity', width: 20 }, + ]); + console.log(`\n${agents.length} agent(s)`); + } +} + +async function getAgent(flags: Record): Promise { + const args = process.argv.slice(4); + const agentId = args[0]; + + if (!agentId) { + printError('Usage: agent get '); + process.exit(1); + } + + const [row] = await db.select().from(records) + .where(eq(records.id, agentId)) + .limit(1); + + if (!row) { + printError(`Agent ${agentId} not found`); + process.exit(1); + } + + const item = { + id: row.id, + ...row.data as Record, + created: row.createdAt.toISOString(), + updated: row.updatedAt.toISOString(), + }; + + if (flags.json === true) { + printJson(item); + } else { + printHeader('Agent Details'); + for (const [key, value] of Object.entries(item)) { + console.log(` ${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`); + } + } +} + +async function createAgent(flags: Record): Promise { + const name = getFlag(flags, 'name'); + const webhookUrl = getFlag(flags, 'webhook-url'); + const apiKey = getFlag(flags, 'api-key'); + + if (!name) { + printError('Required: --name '); + process.exit(1); + } + + const [record] = await db.insert(records).values({ + collection: 'agents', + data: { + name, + webhook_url: webhookUrl, + api_key: apiKey, + active: true, + last_activity_at: null, + }, + }).returning(); + + printSuccess(`Agent created: ${record.id} (${name})`); +} + +async function listAgentTasks(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const agentId = getFlag(flags, 'agent-id'); + const status = getFlag(flags, 'status'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, 'agent_tasks')); + + if (agentId) { + rows = rows.filter((r) => (r.data as Record).agent_id === agentId); + } + if (status) { + rows = rows.filter((r) => (r.data as Record).status === status); + } + + rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + rows = rows.slice(0, limit); + + const tasks = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + agent_id: String(data.agent_id || '').slice(0, 36), + entity_type: data.entity_type, + status: data.status, + instruction: String(data.instruction || '').slice(0, 50), + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(tasks); + } else { + printTable(tasks, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'agent_id', label: 'Agent', width: 36 }, + { key: 'entity_type', label: 'Entity', width: 12 }, + { key: 'status', label: 'Status', width: 12 }, + { key: 'instruction', label: 'Instruction', width: 40 }, + { key: 'created', label: 'Created', width: 20 }, + ]); + console.log(`\n${tasks.length} task(s)`); + } +} + +async function triggerAgent(flags: Record): Promise { + const args = process.argv.slice(4); + const agentId = args[0]; + const instruction = getFlag(flags, 'instruction'); + const entityType = getFlag(flags, 'entity-type', 'note'); + const entityId = getFlag(flags, 'entity-id', ''); + + if (!agentId || !instruction) { + printError('Usage: agent trigger --instruction [--entity-type ] [--entity-id ]'); + process.exit(1); + } + + // Get agent details + const [agentRow] = await db.select().from(records) + .where(eq(records.id, agentId)) + .limit(1); + + if (!agentRow) { + printError(`Agent ${agentId} not found`); + process.exit(1); + } + + const agentData = agentRow.data as Record; + + // Create agent task + const [task] = await db.insert(records).values({ + collection: 'agent_tasks', + data: { + agent_id: agentId, + entity_type: entityType, + entity_id: entityId, + instruction, + status: 'pending', + }, + }).returning(); + + // Queue the job + const [job] = await db.insert(records).values({ + collection: 'queue_jobs', + data: { + type: 'agent_mention', + queue: 'agents', + payload: { + agent_task_id: task.id, + agent_id: agentId, + agent_webhook_url: agentData.webhook_url, + agent_api_key: agentData.api_key, + entity_type: entityType, + entity_id: entityId, + instruction, + }, + status: 'pending', + retry_count: 0, + max_retries: 3, + scheduled_at: new Date().toISOString(), + }, + }).returning(); + + printSuccess(`Agent task created: ${task.id} (job: ${job.id})`); + printSuccess(`Agent "${agentData.name}" will process: ${instruction.slice(0, 60)}`); +} + +async function listActivity(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const agentId = getFlag(flags, 'agent-id'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, 'agent_activity')); + + if (agentId) { + rows = rows.filter((r) => (r.data as Record).agent_id === agentId); + } + + rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + rows = rows.slice(0, limit); + + const activities = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + agent_id: String(data.agent_id || '').slice(0, 36), + action: data.action, + entity_type: data.entity_type, + entity_id: String(data.entity_id || '').slice(0, 36), + description: String(data.description || '').slice(0, 50), + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(activities); + } else { + printTable(activities, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'agent_id', label: 'Agent', width: 36 }, + { key: 'action', label: 'Action', width: 15 }, + { key: 'entity_type', label: 'Entity', width: 12 }, + { key: 'description', label: 'Description', width: 40 }, + { key: 'created', label: 'Created', width: 20 }, + ]); + console.log(`\n${activities.length} activity record(s)`); + } +} + +function showHelp(): void { + console.log(` +Agent Management Commands: + + agent list [--limit N] [--json] + List all agents + + agent get [--json] + Get agent details + + agent create --name [--webhook-url ] [--api-key ] + Create a new agent + + agent tasks [--agent-id ] [--status pending|in_progress|completed] [--limit N] [--json] + List agent tasks + + agent trigger --instruction [--entity-type ] [--entity-id ] + Trigger an agent task + + agent activity [--agent-id ] [--limit N] [--json] + List agent activity records +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'list': + await listAgents(flags); + break; + case 'get': + await getAgent(flags); + break; + case 'create': + await createAgent(flags); + break; + case 'tasks': + await listAgentTasks(flags); + break; + case 'trigger': + await triggerAgent(flags); + break; + case 'activity': + await listActivity(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/commands/data.ts b/packages/cli/commands/data.ts new file mode 100644 index 0000000..5ce2a5f --- /dev/null +++ b/packages/cli/commands/data.ts @@ -0,0 +1,349 @@ +import { parseArgs, getFlag, getFlagNumber, hasFlag, requirePositional } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError, printHeader } from '../lib/output.js'; +import { db, records, eq, sql } from '../lib/db.js'; +import { readFileSync, writeFileSync } from 'node:fs'; + +const COLLECTIONS = [ + 'domains', 'tags', 'projects', 'project_settings', 'milestones', + 'milestone_dependencies', 'milestone_templates', 'milestone_history', 'tasks', + 'task_subtasks', 'task_dependencies', 'task_attachments', 'task_time_entries', + 'time_entries', 'habits', 'habit_logs', 'habit_skip_days', 'notes', 'note_links', + 'note_task_links', 'report_templates', 'reports', 'canvases', 'canvas_cards', + 'agents', 'agent_activity', 'webhooks', 'webhook_deliveries', 'agent_tasks', + 'notifications', 'error_logs', 'queue_jobs', +]; + +function validateCollection(name: string): void { + if (!COLLECTIONS.includes(name)) { + printError(`Unknown collection: ${name}`); + console.log(`Valid collections: ${COLLECTIONS.join(', ')}`); + process.exit(1); + } +} + +async function listRecords(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + if (!collection) { + printError('Usage: data list [--limit N] [--filter "..."] [--sort "field"]'); + process.exit(1); + } + validateCollection(collection); + + const limit = getFlagNumber(flags, 'limit', 50); + const filter = getFlag(flags, 'filter'); + const sort = getFlag(flags, 'sort'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, collection)); + + // Apply sorting + if (sort) { + const desc = sort.startsWith('-'); + const field = desc ? sort.slice(1) : sort; + rows.sort((a, b) => { + const aVal = String((a.data as Record)[field] ?? ''); + const bVal = String((b.data as Record)[field] ?? ''); + return desc ? bVal.localeCompare(aVal) : aVal.localeCompare(bVal); + }); + } + + // Apply limit + rows = rows.slice(0, limit); + + const items = rows.map((r) => ({ + id: r.id, + ...r.data as Record, + created: r.createdAt.toISOString(), + updated: r.updatedAt.toISOString(), + })); + + if (json) { + printJson(items); + } else { + // Auto-detect columns from first record + if (items.length > 0) { + const sampleKeys = Object.keys(items[0]); + const columns = sampleKeys.slice(0, 8).map((key) => ({ + key, + label: key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' '), + width: key === 'id' ? 36 : undefined, + })); + printTable(items, columns); + } else { + printTable(items, []); + } + console.log(`\n${items.length} record(s) in "${collection}"`); + } +} + +async function getRecord(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + const id = args[1]; + + if (!collection || !id) { + printError('Usage: data get '); + process.exit(1); + } + validateCollection(collection); + + const [row] = await db.select().from(records) + .where(eq(records.id, id)) + .limit(1); + + if (!row) { + printError(`Record ${id} not found in "${collection}"`); + process.exit(1); + } + + const item = { + id: row.id, + ...row.data as Record, + created: row.createdAt.toISOString(), + updated: row.updatedAt.toISOString(), + }; + + if (flags.json === true) { + printJson(item); + } else { + printHeader(`${collection} / ${id}`); + for (const [key, value] of Object.entries(item)) { + console.log(` ${key}: ${typeof value === 'object' ? JSON.stringify(value) : value}`); + } + } +} + +async function createRecord(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + + if (!collection) { + printError('Usage: data create --data \'{"key":"val"}\''); + process.exit(1); + } + validateCollection(collection); + + const dataStr = getFlag(flags, 'data'); + const dataFile = getFlag(flags, 'data-file'); + + let data: Record; + if (dataFile) { + data = JSON.parse(readFileSync(dataFile, 'utf-8')); + } else if (dataStr) { + data = JSON.parse(dataStr); + } else { + printError('Required: --data \'{"key":"val"}\' or --data-file '); + process.exit(1); + } + + const [record] = await db.insert(records).values({ + collection, + data, + }).returning(); + + printSuccess(`Created: ${record.id} in "${collection}"`); +} + +async function updateRecord(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + const id = args[1]; + + if (!collection || !id) { + printError('Usage: data update --data \'{"key":"val"}\''); + process.exit(1); + } + validateCollection(collection); + + const dataStr = getFlag(flags, 'data'); + const dataFile = getFlag(flags, 'data-file'); + + let data: Record; + if (dataFile) { + data = JSON.parse(readFileSync(dataFile, 'utf-8')); + } else if (dataStr) { + data = JSON.parse(dataStr); + } else { + printError('Required: --data \'{"key":"val"}\' or --data-file '); + process.exit(1); + } + + // Get existing record to merge data + const [existing] = await db.select().from(records) + .where(eq(records.id, id)) + .limit(1); + + if (!existing) { + printError(`Record ${id} not found in "${collection}"`); + process.exit(1); + } + + const [updated] = await db.update(records) + .set({ data: { ...(existing.data as Record), ...data }, updatedAt: new Date() }) + .where(eq(records.id, id)) + .returning(); + + printSuccess(`Updated: ${updated.id} in "${collection}"`); +} + +async function deleteRecord(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + const id = args[1]; + + if (!collection || !id) { + printError('Usage: data delete '); + process.exit(1); + } + validateCollection(collection); + + const deleted = await db.delete(records) + .where(eq(records.id, id)) + .returning(); + + if (deleted.length === 0) { + printError(`Record ${id} not found in "${collection}"`); + process.exit(1); + } + + printSuccess(`Deleted: ${id} from "${collection}"`); +} + +async function exportCollection(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + + if (!collection) { + printError('Usage: data export [--output file.json]'); + process.exit(1); + } + validateCollection(collection); + + const rows = await db.select().from(records) + .where(eq(records.collection, collection)); + + const items = rows.map((r) => ({ + id: r.id, + ...r.data as Record, + created: r.createdAt.toISOString(), + updated: r.updatedAt.toISOString(), + })); + + const output = getFlag(flags, 'output'); + if (output) { + writeFileSync(output, JSON.stringify(items, null, 2)); + printSuccess(`Exported ${items.length} records to ${output}`); + } else { + printJson(items); + } +} + +async function importCollection(flags: Record): Promise { + const args = process.argv.slice(4); + const collection = args[0]; + const file = args[1]; + + if (!collection || !file) { + printError('Usage: data import [--dry-run]'); + process.exit(1); + } + validateCollection(collection); + + const data = JSON.parse(readFileSync(file, 'utf-8')); + if (!Array.isArray(data)) { + printError('Import file must contain a JSON array of records'); + process.exit(1); + } + + const dryRun = hasFlag(flags, 'dry-run'); + + if (dryRun) { + printHeader(`Dry Run: ${data.length} records to import into "${collection}"`); + for (const item of data.slice(0, 10)) { + console.log(` - ${item.title || item.name || item.email || JSON.stringify(item).slice(0, 60)}`); + } + if (data.length > 10) console.log(` ... and ${data.length - 10} more`); + return; + } + + let created = 0; + let errors = 0; + + for (const item of data) { + try { + const { id: _id, created: _created, updated: _updated, ...rest } = item; + await db.insert(records).values({ collection, data: rest }); + created++; + } catch (error) { + errors++; + printError(`Failed to import record: ${error instanceof Error ? error.message : String(error)}`); + } + } + + printSuccess(`Imported ${created} records into "${collection}" (${errors} errors)`); +} + +function showHelp(): void { + console.log(` +Data Management Commands: + + data list [--limit N] [--sort "field"] [--json] + List records in a collection + + data get [--json] + Get a single record by ID + + data create --data '{"key":"val"}' + Create a new record + + data create --data-file path/to/data.json + Create from a JSON file + + data update --data '{"key":"val"}' + Update an existing record + + data delete + Delete a record + + data export [--output file.json] + Export all records to JSON (stdout if no output file) + + data import [--dry-run] + Import records from a JSON file + +Valid collections: + ${COLLECTIONS.join(', ')} +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'list': + await listRecords(flags); + break; + case 'get': + await getRecord(flags); + break; + case 'create': + await createRecord(flags); + break; + case 'update': + await updateRecord(flags); + break; + case 'delete': + await deleteRecord(flags); + break; + case 'export': + await exportCollection(flags); + break; + case 'import': + await importCollection(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/commands/health.ts b/packages/cli/commands/health.ts new file mode 100644 index 0000000..77a4b45 --- /dev/null +++ b/packages/cli/commands/health.ts @@ -0,0 +1,159 @@ +import { parseArgs, getFlag, getFlagNumber, hasFlag } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError, printHeader } from '../lib/output.js'; +import { db, records, eq, rawQuery } from '../lib/db.js'; + +async function checkHealth(): Promise { + printHeader('System Health Check'); + + // Test DB connection + try { + const start = Date.now(); + await rawQuery('SELECT 1 as ok'); + const latency = Date.now() - start; + printSuccess(`Database connection OK (${latency}ms)`); + } catch (error) { + printError(`Database connection FAILED: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + } + + // Count records per collection + const collections = [ + 'users', 'domains', 'tags', 'projects', 'tasks', 'habits', 'habit_logs', + 'notes', 'agents', 'agent_tasks', 'webhooks', 'webhook_deliveries', + 'queue_jobs', 'error_logs', 'reports', + ]; + + console.log('\nCollection Status:'); + const stats: { name: string; count: number }[] = []; + + for (const name of collections) { + const result = await rawQuery( + 'SELECT COUNT(*) as count FROM records WHERE collection = $1', + [name] + ); + const count = Number((result[0] as Record).count); + stats.push({ name, count }); + } + + printTable(stats, [ + { key: 'name', label: 'Collection' }, + { key: 'count', label: 'Count', align: 'right' }, + ]); + + // Check worker queue + const pendingResult = await rawQuery( + "SELECT COUNT(*) as count FROM records WHERE collection = 'queue_jobs' AND data->>'status' = 'pending'" + ); + const pending = Number((pendingResult[0] as Record).count); + + const failedResult = await rawQuery( + "SELECT COUNT(*) as count FROM records WHERE collection = 'queue_jobs' AND data->>'status' = 'failed'" + ); + const failed = Number((failedResult[0] as Record).count); + + console.log('\nWorker Queue:'); + console.log(` Pending jobs: ${pending}`); + console.log(` Failed jobs: ${failed}`); +} + +async function listErrors(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const level = getFlag(flags, 'level'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, 'error_logs')); + + if (level) { + rows = rows.filter((r) => (r.data as Record).level === level); + } + + // Sort by created descending (most recent first) + rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + rows = rows.slice(0, limit); + + const items = rows.map((r) => ({ + id: r.id, + ...(r.data as Record), + created: r.createdAt.toISOString(), + })); + + if (json) { + printJson(items); + } else { + printTable(items, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'level', label: 'Level', width: 8 }, + { key: 'source', label: 'Source', width: 20 }, + { key: 'message', label: 'Message', width: 40 }, + { key: 'created', label: 'Created', width: 20 }, + ]); + console.log(`\n${items.length} error(s)`); + } +} + +async function showStats(flags: Record): Promise { + const json = flags.json === true; + + const totalResult = await rawQuery('SELECT COUNT(*) as count FROM records'); + const total = Number((totalResult[0] as Record).count); + + const collectionsResult = await rawQuery( + 'SELECT collection, COUNT(*) as count FROM records GROUP BY collection ORDER BY count DESC' + ); + const collections = (collectionsResult as Record[]).map((r) => ({ + collection: r.collection as string, + count: Number(r.count), + })); + + const usersResult = await rawQuery( + "SELECT COUNT(*) as count FROM records WHERE collection = 'users'" + ); + const users = Number((usersResult[0] as Record).count); + + if (json) { + printJson({ total, users, collections }); + } else { + printHeader('Database Statistics'); + console.log(` Total records: ${total}`); + console.log(` Total users: ${users}`); + console.log('\nRecords by collection:'); + printTable(collections, [ + { key: 'collection', label: 'Collection' }, + { key: 'count', label: 'Count', align: 'right' }, + ]); + } +} + +function showHelp(): void { + console.log(` +Health & Diagnostics Commands: + + health check + Test database connectivity and show system status + + health errors [--limit N] [--level error|warn] [--json] + List recent error logs + + health stats [--json] + Show database statistics (record counts by collection) +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'check': + await checkHealth(); + break; + case 'errors': + await listErrors(flags); + break; + case 'stats': + await showStats(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/commands/user.ts b/packages/cli/commands/user.ts new file mode 100644 index 0000000..9c180ab --- /dev/null +++ b/packages/cli/commands/user.ts @@ -0,0 +1,149 @@ +import { parseArgs, getFlag, getFlagNumber, requirePositional } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError } from '../lib/output.js'; +import { db, records, eq, sql } from '../lib/db.js'; +import { createHash, randomBytes } from 'node:crypto'; + +async function hashPassword(password: string): Promise { + const salt = randomBytes(16).toString('hex'); + return new Promise((resolve, reject) => { + createHash('sha256').update(salt + password).digest('hex', (err, hash) => { + if (err) reject(err); + resolve(`${salt}:${hash}`); + }); + }); +} + +async function listUsers(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const json = flags.json === true; + + const rows = await db.select().from(records) + .where(eq(records.collection, 'users')) + .limit(limit); + + const users = rows.map((r) => ({ + id: r.id, + ...r.data as Record, + created: r.createdAt.toISOString(), + })); + + if (json) { + printJson(users); + } else { + printTable(users, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'email', label: 'Email' }, + { key: 'name', label: 'Name' }, + { key: 'created', label: 'Created', width: 20 }, + ]); + console.log(`\n${users.length} user(s)`); + } +} + +async function createUser(flags: Record): Promise { + const email = getFlag(flags, 'email'); + const name = getFlag(flags, 'name'); + const password = getFlag(flags, 'password'); + + if (!email || !name || !password) { + printError('Required flags: --email, --name, --password'); + process.exit(1); + } + + const passwordHash = await hashPassword(password); + + const [record] = await db.insert(records).values({ + collection: 'users', + data: { email, name, passwordHash }, + }).returning(); + + printSuccess(`User created: ${record.id} (${email})`); +} + +async function deleteUser(flags: Record): Promise { + const id = requirePositional(Object.entries(flags).length ? [] : [], 0, 'user ID'); + + // Try to get from positional args passed from main + const args = process.argv.slice(4); // after "user delete" + const userId = args[0] || id; + + if (!userId) { + printError('Usage: user delete '); + process.exit(1); + } + + const deleted = await db.delete(records) + .where(eq(records.id, userId)) + .returning(); + + if (deleted.length === 0) { + printError(`User ${userId} not found`); + process.exit(1); + } + + printSuccess(`User deleted: ${userId}`); +} + +async function resetPassword(flags: Record): Promise { + const args = process.argv.slice(4); + const userId = args[0]; + const newPassword = getFlag(flags, 'password'); + + if (!userId || !newPassword) { + printError('Usage: user reset-password --password '); + process.exit(1); + } + + const passwordHash = await hashPassword(newPassword); + + const updated = await db.update(records) + .set({ data: { passwordHash } }) + .where(eq(records.id, userId)) + .returning(); + + if (updated.length === 0) { + printError(`User ${userId} not found`); + process.exit(1); + } + + printSuccess(`Password reset for user: ${userId}`); +} + +function showHelp(): void { + console.log(` +User Management Commands: + + user list [--limit N] [--json] + List all users + + user create --email --name --password + Create a new user + + user delete + Delete a user by ID + + user reset-password --password + Reset a user's password +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'list': + await listUsers(flags); + break; + case 'create': + await createUser(flags); + break; + case 'delete': + await deleteUser(flags); + break; + case 'reset-password': + await resetPassword(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/commands/webhook.ts b/packages/cli/commands/webhook.ts new file mode 100644 index 0000000..7954846 --- /dev/null +++ b/packages/cli/commands/webhook.ts @@ -0,0 +1,228 @@ +import { parseArgs, getFlag, getFlagNumber } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError, printHeader } from '../lib/output.js'; +import { db, records, eq, sql } from '../lib/db.js'; + +async function listWebhooks(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const json = flags.json === true; + + const rows = await db.select().from(records) + .where(eq(records.collection, 'webhooks')) + .limit(limit); + + const webhooks = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + name: data.name || '', + url: String(data.url || '').slice(0, 50), + active: data.active, + events: Array.isArray(data.events) ? data.events.join(', ') : '', + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(webhooks); + } else { + printTable(webhooks, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'name', label: 'Name', width: 20 }, + { key: 'url', label: 'URL', width: 40 }, + { key: 'active', label: 'Active', width: 8 }, + { key: 'events', label: 'Events', width: 30 }, + ]); + console.log(`\n${webhooks.length} webhook(s)`); + } +} + +async function testWebhook(flags: Record): Promise { + const args = process.argv.slice(4); + const webhookId = args[0]; + + if (!webhookId) { + printError('Usage: webhook test '); + process.exit(1); + } + + const [row] = await db.select().from(records) + .where(eq(records.id, webhookId)) + .limit(1); + + if (!row) { + printError(`Webhook ${webhookId} not found`); + process.exit(1); + } + + const data = row.data as Record; + const url = data.url as string; + + if (!url) { + printError('Webhook has no URL configured'); + process.exit(1); + } + + console.log(`Testing webhook: ${data.name || webhookId}`); + console.log(`URL: ${url}`); + + const start = Date.now(); + try { + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Event-Type': 'test.ping', + }, + body: JSON.stringify({ + event: 'test.ping', + timestamp: new Date().toISOString(), + data: { message: 'Test delivery from Project E CLI' }, + }), + signal: AbortSignal.timeout(10000), + }); + + const latency = Date.now() - start; + const body = await response.text(); + + if (response.ok) { + printSuccess(`Webhook responded: ${response.status} (${latency}ms)`); + } else { + printError(`Webhook failed: ${response.status} (${latency}ms)`); + } + + if (body) { + console.log(`Response: ${body.slice(0, 200)}`); + } + } catch (error) { + const latency = Date.now() - start; + printError(`Webhook request failed (${latency}ms): ${error instanceof Error ? error.message : String(error)}`); + } +} + +async function listDeliveries(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const webhookId = getFlag(flags, 'webhook-id'); + const status = getFlag(flags, 'status'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, 'webhook_deliveries')); + + if (webhookId) { + rows = rows.filter((r) => (r.data as Record).webhook_id === webhookId); + } + if (status) { + rows = rows.filter((r) => (r.data as Record).status === status); + } + + // Sort by created descending + rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + rows = rows.slice(0, limit); + + const deliveries = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + webhook_id: String(data.webhook_id || '').slice(0, 36), + event: data.event, + status: data.status, + status_code: data.status_code, + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(deliveries); + } else { + printTable(deliveries, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'event', label: 'Event', width: 20 }, + { key: 'status', label: 'Status', width: 10 }, + { key: 'status_code', label: 'HTTP', width: 6, align: 'right' }, + { key: 'created', label: 'Created', width: 20 }, + ]); + console.log(`\n${deliveries.length} delivery(ies)`); + } +} + +async function retryDelivery(flags: Record): Promise { + const args = process.argv.slice(4); + const deliveryId = args[0]; + + if (!deliveryId) { + printError('Usage: webhook retry '); + process.exit(1); + } + + const [row] = await db.select().from(records) + .where(eq(records.id, deliveryId)) + .limit(1); + + if (!row) { + printError(`Delivery ${deliveryId} not found`); + process.exit(1); + } + + const data = row.data as Record; + + // Create a new queue job for retry + const [job] = await db.insert(records).values({ + collection: 'queue_jobs', + data: { + type: 'webhook_delivery', + queue: 'webhooks', + payload: { + webhook_id: data.webhook_id, + webhook_url: data.webhook_url || '', + webhook_secret: data.webhook_secret || '', + event_type: data.event, + event_payload: data.payload, + }, + status: 'pending', + retry_count: 0, + max_retries: 3, + scheduled_at: new Date().toISOString(), + }, + }).returning(); + + printSuccess(`Retry queued: job ${job.id}`); +} + +function showHelp(): void { + console.log(` +Webhook Management Commands: + + webhook list [--limit N] [--json] + List all webhooks + + webhook test + Send a test ping to a webhook URL + + webhook deliveries [--webhook-id ] [--status success|failed] [--limit N] [--json] + List webhook delivery records + + webhook retry + Re-queue a failed delivery as a new job +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'list': + await listWebhooks(flags); + break; + case 'test': + await testWebhook(flags); + break; + case 'deliveries': + await listDeliveries(flags); + break; + case 'retry': + await retryDelivery(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/commands/worker.ts b/packages/cli/commands/worker.ts new file mode 100644 index 0000000..de788e5 --- /dev/null +++ b/packages/cli/commands/worker.ts @@ -0,0 +1,180 @@ +import { parseArgs, getFlag, getFlagNumber, hasFlag } from '../lib/args.js'; +import { printTable, printJson, printSuccess, printError, printHeader } from '../lib/output.js'; +import { db, records, eq, rawQuery } from '../lib/db.js'; + +async function showStatus(flags: Record): Promise { + const json = flags.json === true; + + const rows = await db.select().from(records) + .where(eq(records.collection, 'queue_jobs')); + + const statusCounts: Record = {}; + for (const row of rows) { + const status = (row.data as Record).status as string || 'unknown'; + statusCounts[status] = (statusCounts[status] || 0) + 1; + } + + const stats = Object.entries(statusCounts).map(([status, count]) => ({ status, count })); + + if (json) { + printJson({ total: rows.length, byStatus: statusCounts }); + } else { + printHeader('Worker Queue Status'); + printTable(stats, [ + { key: 'status', label: 'Status' }, + { key: 'count', label: 'Count', align: 'right' }, + ]); + console.log(`\nTotal jobs: ${rows.length}`); + } +} + +async function listJobs(flags: Record): Promise { + const limit = getFlagNumber(flags, 'limit', 50); + const status = getFlag(flags, 'status'); + const type = getFlag(flags, 'type'); + const json = flags.json === true; + + let rows = await db.select().from(records) + .where(eq(records.collection, 'queue_jobs')); + + if (status) { + rows = rows.filter((r) => (r.data as Record).status === status); + } + if (type) { + rows = rows.filter((r) => (r.data as Record).type === type); + } + + // Sort by created descending + rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + rows = rows.slice(0, limit); + + const jobs = rows.map((r) => { + const data = r.data as Record; + return { + id: r.id, + type: data.type, + status: data.status, + queue: data.queue, + retry_count: data.retry_count ?? 0, + max_retries: data.max_retries ?? 3, + error: data.error ? String(data.error).slice(0, 50) : '', + scheduled_at: data.scheduled_at ? String(data.scheduled_at).slice(0, 19) : '', + created: r.createdAt.toISOString().slice(0, 19), + }; + }); + + if (json) { + printJson(jobs); + } else { + printTable(jobs, [ + { key: 'id', label: 'ID', width: 36 }, + { key: 'type', label: 'Type', width: 20 }, + { key: 'status', label: 'Status', width: 12 }, + { key: 'retry_count', label: 'Retries', width: 8, align: 'right' }, + { key: 'error', label: 'Error', width: 30 }, + ]); + console.log(`\n${jobs.length} job(s)`); + } +} + +async function retryJob(flags: Record): Promise { + const args = process.argv.slice(4); + const jobId = args[0]; + + if (!jobId) { + printError('Usage: worker retry '); + process.exit(1); + } + + const [existing] = await db.select().from(records) + .where(eq(records.id, jobId)) + .limit(1); + + if (!existing) { + printError(`Job ${jobId} not found`); + process.exit(1); + } + + const data = existing.data as Record; + if (data.status !== 'failed') { + printError(`Job ${jobId} is not in "failed" status (current: ${data.status})`); + process.exit(1); + } + + await db.update(records) + .set({ + data: { ...data, status: 'pending', error: null, retry_count: 0 }, + updatedAt: new Date(), + }) + .where(eq(records.id, jobId)); + + printSuccess(`Job ${jobId} reset to pending`); +} + +async function triggerJob(flags: Record): Promise { + const args = process.argv.slice(4); + const type = args[0]; + + if (!type) { + printError('Usage: worker trigger [--payload \'{"key":"val"}\'] [--queue ]'); + process.exit(1); + } + + const payloadStr = getFlag(flags, 'payload'); + const queue = getFlag(flags, 'queue', 'default'); + const payload = payloadStr ? JSON.parse(payloadStr) : {}; + + const [record] = await db.insert(records).values({ + collection: 'queue_jobs', + data: { + type, + queue, + payload, + status: 'pending', + retry_count: 0, + max_retries: 3, + scheduled_at: new Date().toISOString(), + }, + }).returning(); + + printSuccess(`Job created: ${record.id} (type: ${type}, queue: ${queue})`); +} + +function showHelp(): void { + console.log(` +Worker/Queue Management Commands: + + worker status [--json] + Show queue status (counts by status) + + worker jobs [--status ] [--type ] [--limit N] [--json] + List queue jobs with optional filters + + worker retry + Reset a failed job to pending status + + worker trigger [--payload '{"key":"val"}'] [--queue ] + Create and queue a new job +`); +} + +export default async function main(): Promise { + const { subcommand, flags } = parseArgs(process.argv); + + switch (subcommand) { + case 'status': + await showStatus(flags); + break; + case 'jobs': + await listJobs(flags); + break; + case 'retry': + await retryJob(flags); + break; + case 'trigger': + await triggerJob(flags); + break; + default: + showHelp(); + } +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100755 index 0000000..7570064 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { parseArgs } from './lib/args.js'; +import { printError, printHeader, printJson } from './lib/output.js'; + +const commands: Record Promise> = { + user: () => import('./commands/user.js').then((m) => m.default()), + data: () => import('./commands/data.js').then((m) => m.default()), + worker: () => import('./commands/worker.js').then((m) => m.default()), + health: () => import('./commands/health.js').then((m) => m.default()), + webhook: () => import('./commands/webhook.js').then((m) => m.default()), + agent: () => import('./commands/agent.js').then((m) => m.default()), +}; + +function showHelp(): void { + printHeader('Project E Admin CLI'); + console.log(` +Usage: npx tsx packages/cli/index.ts [subcommand] [options] + +Commands: + user User management (list, create, delete, reset-password) + data Data CRUD and import/export for all collections + worker Queue and worker management + health System health and diagnostics + webhook Webhook management and testing + agent Agent management and task triggers + +Global Options: + --json Output results as JSON + --help Show help for a command + +Examples: + npx tsx packages/cli/index.ts health check + npx tsx packages/cli/index.ts user list --json + npx tsx packages/cli/index.ts data list projects --limit 10 + npx tsx packages/cli/index.ts worker status +`); +} + +async function main(): Promise { + const { command, flags } = parseArgs(process.argv); + + if (!command || command === 'help' || hasFlag(flags, 'help')) { + showHelp(); + return; + } + + const handler = commands[command]; + if (!handler) { + printError(`Unknown command: ${command}`); + console.log('Run with --help to see available commands.'); + process.exit(1); + } + + try { + await handler(); + } catch (error) { + printError(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} + +function hasFlag(flags: Record, name: string): boolean { + return flags[name] !== undefined; +} + +main(); diff --git a/packages/cli/lib/args.ts b/packages/cli/lib/args.ts new file mode 100644 index 0000000..9ca5a7a --- /dev/null +++ b/packages/cli/lib/args.ts @@ -0,0 +1,72 @@ +export interface ParsedArgs { + command: string; + subcommand: string; + positional: string[]; + flags: Record; +} + +export function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2); // skip node and script path + const flags: Record = {}; + const positional: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith('--')) { + const key = arg.slice(2); + // Check if next arg is a value (not a flag) + const next = args[i + 1]; + if (next && !next.startsWith('--')) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else if (arg.startsWith('-') && arg.length === 2) { + // Short flags like -j + const key = arg.slice(1); + const next = args[i + 1]; + if (next && !next.startsWith('-')) { + flags[key] = next; + i++; + } else { + flags[key] = true; + } + } else { + positional.push(arg); + } + } + + return { + command: positional[0] || '', + subcommand: positional[1] || '', + positional: positional.slice(2), + flags, + }; +} + +export function getFlag(flags: Record, name: string, defaultValue = ''): string { + const val = flags[name]; + if (val === undefined || val === true) return defaultValue; + return String(val); +} + +export function getFlagNumber(flags: Record, name: string, defaultValue: number): number { + const val = getFlag(flags, name); + if (!val) return defaultValue; + const num = parseInt(val, 10); + return isNaN(num) ? defaultValue : num; +} + +export function hasFlag(flags: Record, name: string): boolean { + return flags[name] !== undefined; +} + +export function requirePositional(positional: string[], index: number, name: string): string { + const val = positional[index]; + if (!val) { + console.error(`Error: Missing required argument: ${name}`); + process.exit(1); + } + return val; +} diff --git a/packages/cli/lib/db.ts b/packages/cli/lib/db.ts new file mode 100644 index 0000000..5bd7749 --- /dev/null +++ b/packages/cli/lib/db.ts @@ -0,0 +1,47 @@ +import { config } from 'dotenv'; +import { resolve } from 'node:path'; +import { existsSync } from 'node:fs'; +import { and, eq } from 'drizzle-orm'; + +// Load .env.local from project root +const envPaths = [ + resolve(process.cwd(), '.env.local'), + resolve(process.cwd(), '.env'), +]; + +for (const p of envPaths) { + if (existsSync(p)) { + config({ path: p }); + break; + } +} + +// Default DATABASE_URL for local development +const defaultUrl = 'postgresql://project_e:development-password@localhost:5432/project_e'; +if (!process.env.DATABASE_URL) { + process.env.DATABASE_URL = defaultUrl; +} + +// Import db package after env is set +const { db, sql: drizzleSql } = await import('@project-e/db'); +const { records } = await import('@project-e/db/schema'); + +// Re-export everything +export { db, records, and, eq }; + +// Export a raw SQL function for direct queries +export async function rawQuery(query: string, params?: unknown[]): Promise { + const result = await drizzleSql.unsafe(query, params); + return result; +} + +export type RecordData = Record; + +export function serializeRecord(record: typeof records.$inferSelect): RecordData { + return { + ...(record.data as RecordData), + id: record.id, + created: record.createdAt.toISOString(), + updated: record.updatedAt.toISOString(), + }; +} diff --git a/packages/cli/lib/output.ts b/packages/cli/lib/output.ts new file mode 100644 index 0000000..c20d770 --- /dev/null +++ b/packages/cli/lib/output.ts @@ -0,0 +1,51 @@ +type Column = { key: string; label: string; width?: number; align?: 'left' | 'right' }; + +export function printTable(rows: Record[], columns: Column[]): void { + if (rows.length === 0) { + console.log('(no records)'); + return; + } + + // Calculate widths + const widths = columns.map((col) => { + if (col.width) return col.width; + const headerLen = col.label.length; + const maxDataLen = rows.reduce((max, row) => { + const val = String(row[col.key] ?? ''); + return Math.max(max, val.length); + }, 0); + return Math.max(headerLen, maxDataLen); + }); + + // Print header + const header = columns.map((col, i) => col.label.padEnd(widths[i])).join(' '); + console.log(header); + console.log(widths.map((w) => '-'.repeat(w)).join(' ')); + + // Print rows + for (const row of rows) { + const line = columns.map((col, i) => { + const val = String(row[col.key] ?? ''); + return col.align === 'right' ? val.padStart(widths[i]) : val.padEnd(widths[i]); + }).join(' '); + console.log(line); + } +} + +export function printJson(data: unknown): void { + console.log(JSON.stringify(data, null, 2)); +} + +export function printSuccess(message: string): void { + console.log(`✓ ${message}`); +} + +export function printError(message: string): void { + console.error(`✗ ${message}`); +} + +export function printHeader(message: string): void { + console.log(`\n${'='.repeat(50)}`); + console.log(` ${message}`); + console.log(`${'='.repeat(50)}`); +} diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..ea0b907 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,21 @@ +{ + "name": "@project-e/cli", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "project-e-cli": "./index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@project-e/db": "^0.1.0", + "@project-e/shared": "*", + "dotenv": "^16.4.7" + }, + "devDependencies": { + "@types/node": "^22.19.0", + "typescript": "^5.9.3" + } +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..0a754d9 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "incremental": true + }, + "include": ["*.ts", "lib/**/*.ts", "commands/**/*.ts"] +}