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:
+6
-3
@@ -5,7 +5,8 @@
|
|||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*",
|
"apps/*",
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"worker"
|
"worker",
|
||||||
|
"packages/cli"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "turbo dev",
|
"dev": "turbo dev",
|
||||||
@@ -15,11 +16,13 @@
|
|||||||
"test:e2e": "playwright test",
|
"test:e2e": "playwright test",
|
||||||
"test:e2e:ui": "playwright test --ui",
|
"test:e2e:ui": "playwright test --ui",
|
||||||
"test:e2e:report": "playwright show-report",
|
"test:e2e:report": "playwright show-report",
|
||||||
"db:generate": "turbo db:generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"typecheck": "turbo typecheck"
|
"typecheck": "turbo typecheck",
|
||||||
|
"cli": "tsx packages/cli/index.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"turbo": "^2.5.0",
|
"turbo": "^2.5.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+67
@@ -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();
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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)}`);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user