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>
This commit is contained in:
2026-07-24 07:02:11 -04:00
parent ec33544172
commit 6c438eab32
13 changed files with 1644 additions and 3 deletions
+6 -3
View File
@@ -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"
+298
View File
@@ -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<string, string | boolean>): Promise<void> {
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<string, unknown>;
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const agentId = args[0];
if (!agentId) {
printError('Usage: agent get <id>');
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<string, unknown>,
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<string, string | boolean>): Promise<void> {
const name = getFlag(flags, 'name');
const webhookUrl = getFlag(flags, 'webhook-url');
const apiKey = getFlag(flags, 'api-key');
if (!name) {
printError('Required: --name <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<string, string | boolean>): Promise<void> {
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<string, unknown>).agent_id === agentId);
}
if (status) {
rows = rows.filter((r) => (r.data as Record<string, unknown>).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<string, unknown>;
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<string, string | boolean>): Promise<void> {
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 <agentId> --instruction <text> [--entity-type <type>] [--entity-id <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<string, unknown>;
// 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<string, string | boolean>): Promise<void> {
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<string, unknown>).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<string, unknown>;
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 <id> [--json]
Get agent details
agent create --name <name> [--webhook-url <url>] [--api-key <key>]
Create a new agent
agent tasks [--agent-id <id>] [--status pending|in_progress|completed] [--limit N] [--json]
List agent tasks
agent trigger <agentId> --instruction <text> [--entity-type <type>] [--entity-id <id>]
Trigger an agent task
agent activity [--agent-id <id>] [--limit N] [--json]
List agent activity records
`);
}
export default async function main(): Promise<void> {
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();
}
}
+349
View File
@@ -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<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();
}
}
+159
View File
@@ -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<void> {
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<string, unknown>).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<string, unknown>).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<string, unknown>).count);
console.log('\nWorker Queue:');
console.log(` Pending jobs: ${pending}`);
console.log(` Failed jobs: ${failed}`);
}
async function listErrors(flags: Record<string, string | boolean>): Promise<void> {
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<string, unknown>).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<string, unknown>),
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<string, string | boolean>): Promise<void> {
const json = flags.json === true;
const totalResult = await rawQuery('SELECT COUNT(*) as count FROM records');
const total = Number((totalResult[0] as Record<string, unknown>).count);
const collectionsResult = await rawQuery(
'SELECT collection, COUNT(*) as count FROM records GROUP BY collection ORDER BY count DESC'
);
const collections = (collectionsResult as Record<string, unknown>[]).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<string, unknown>).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<void> {
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();
}
}
+149
View File
@@ -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<string> {
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<string, string | boolean>): Promise<void> {
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<string, unknown>,
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<string, string | boolean>): Promise<void> {
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<string, string | boolean>): Promise<void> {
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 <id>');
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const userId = args[0];
const newPassword = getFlag(flags, 'password');
if (!userId || !newPassword) {
printError('Usage: user reset-password <id> --password <new-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 <email> --name <name> --password <password>
Create a new user
user delete <id>
Delete a user by ID
user reset-password <id> --password <new-password>
Reset a user's password
`);
}
export default async function main(): Promise<void> {
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();
}
}
+228
View File
@@ -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<string, string | boolean>): Promise<void> {
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<string, unknown>;
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const webhookId = args[0];
if (!webhookId) {
printError('Usage: webhook test <id>');
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<string, unknown>;
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<string, string | boolean>): Promise<void> {
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<string, unknown>).webhook_id === webhookId);
}
if (status) {
rows = rows.filter((r) => (r.data as Record<string, unknown>).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<string, unknown>;
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const deliveryId = args[0];
if (!deliveryId) {
printError('Usage: webhook retry <deliveryId>');
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<string, unknown>;
// 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 <id>
Send a test ping to a webhook URL
webhook deliveries [--webhook-id <id>] [--status success|failed] [--limit N] [--json]
List webhook delivery records
webhook retry <deliveryId>
Re-queue a failed delivery as a new job
`);
}
export default async function main(): Promise<void> {
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();
}
}
+180
View File
@@ -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<string, string | boolean>): Promise<void> {
const json = flags.json === true;
const rows = await db.select().from(records)
.where(eq(records.collection, 'queue_jobs'));
const statusCounts: Record<string, number> = {};
for (const row of rows) {
const status = (row.data as Record<string, unknown>).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<string, string | boolean>): Promise<void> {
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<string, unknown>).status === status);
}
if (type) {
rows = rows.filter((r) => (r.data as Record<string, unknown>).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<string, unknown>;
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const jobId = args[0];
if (!jobId) {
printError('Usage: worker retry <jobId>');
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<string, unknown>;
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<string, string | boolean>): Promise<void> {
const args = process.argv.slice(4);
const type = args[0];
if (!type) {
printError('Usage: worker trigger <type> [--payload \'{"key":"val"}\'] [--queue <name>]');
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 <status>] [--type <type>] [--limit N] [--json]
List queue jobs with optional filters
worker retry <jobId>
Reset a failed job to pending status
worker trigger <type> [--payload '{"key":"val"}'] [--queue <name>]
Create and queue a new job
`);
}
export default async function main(): Promise<void> {
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();
}
}
+67
View File
@@ -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<string, () => Promise<void>> = {
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 <command> [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<void> {
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<string, string | boolean>, name: string): boolean {
return flags[name] !== undefined;
}
main();
+72
View File
@@ -0,0 +1,72 @@
export interface ParsedArgs {
command: string;
subcommand: string;
positional: string[];
flags: Record<string, string | boolean>;
}
export function parseArgs(argv: string[]): ParsedArgs {
const args = argv.slice(2); // skip node and script path
const flags: Record<string, string | boolean> = {};
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<string, string | boolean>, name: string, defaultValue = ''): string {
const val = flags[name];
if (val === undefined || val === true) return defaultValue;
return String(val);
}
export function getFlagNumber(flags: Record<string, string | boolean>, 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<string, string | boolean>, 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;
}
+47
View File
@@ -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<unknown[]> {
const result = await drizzleSql.unsafe(query, params);
return result;
}
export type RecordData = Record<string, unknown>;
export function serializeRecord(record: typeof records.$inferSelect): RecordData {
return {
...(record.data as RecordData),
id: record.id,
created: record.createdAt.toISOString(),
updated: record.updatedAt.toISOString(),
};
}
+51
View File
@@ -0,0 +1,51 @@
type Column = { key: string; label: string; width?: number; align?: 'left' | 'right' };
export function printTable(rows: Record<string, unknown>[], 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)}`);
}
+21
View File
@@ -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"
}
}
+17
View File
@@ -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"]
}