Files
ProjectE/packages/cli/commands/data.ts
T
mbatchelder 6c438eab32 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 -- <command>
2026-07-24 07:02:11 -04:00

350 lines
9.8 KiB
TypeScript

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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
if (!collection) {
printError('Usage: data list <collection> [--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<string, unknown>)[field] ?? '');
const bVal = String((b.data as Record<string, unknown>)[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<string, unknown>,
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
const id = args[1];
if (!collection || !id) {
printError('Usage: data get <collection> <id>');
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<string, unknown>,
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
if (!collection) {
printError('Usage: data create <collection> --data \'{"key":"val"}\'');
process.exit(1);
}
validateCollection(collection);
const dataStr = getFlag(flags, 'data');
const dataFile = getFlag(flags, 'data-file');
let data: Record<string, unknown>;
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 <path>');
process.exit(1);
}
const [record] = await db.insert(records).values({
collection,
data,
}).returning();
printSuccess(`Created: ${record.id} in "${collection}"`);
}
async function updateRecord(flags: Record<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
const id = args[1];
if (!collection || !id) {
printError('Usage: data update <collection> <id> --data \'{"key":"val"}\'');
process.exit(1);
}
validateCollection(collection);
const dataStr = getFlag(flags, 'data');
const dataFile = getFlag(flags, 'data-file');
let data: Record<string, unknown>;
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 <path>');
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<string, unknown>), ...data }, updatedAt: new Date() })
.where(eq(records.id, id))
.returning();
printSuccess(`Updated: ${updated.id} in "${collection}"`);
}
async function deleteRecord(flags: Record<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
const id = args[1];
if (!collection || !id) {
printError('Usage: data delete <collection> <id>');
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
if (!collection) {
printError('Usage: data export <collection> [--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<string, unknown>,
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const collection = args[0];
const file = args[1];
if (!collection || !file) {
printError('Usage: data import <collection> <file.json> [--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 <collection> [--limit N] [--sort "field"] [--json]
List records in a collection
data get <collection> <id> [--json]
Get a single record by ID
data create <collection> --data '{"key":"val"}'
Create a new record
data create <collection> --data-file path/to/data.json
Create from a JSON file
data update <collection> <id> --data '{"key":"val"}'
Update an existing record
data delete <collection> <id>
Delete a record
data export <collection> [--output file.json]
Export all records to JSON (stdout if no output file)
data import <collection> <file.json> [--dry-run]
Import records from a JSON file
Valid collections:
${COLLECTIONS.join(', ')}
`);
}
export default async function main(): Promise<void> {
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();
}
}