optimal-cli 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +1590 -0
- package/dist/lib/assets/index.d.ts +79 -0
- package/dist/lib/assets/index.js +153 -0
- package/dist/lib/assets.d.ts +20 -0
- package/dist/lib/assets.js +112 -0
- package/dist/lib/auth/index.d.ts +83 -0
- package/dist/lib/auth/index.js +146 -0
- package/dist/lib/board/index.d.ts +39 -0
- package/dist/lib/board/index.js +285 -0
- package/dist/lib/board/types.d.ts +111 -0
- package/dist/lib/board/types.js +1 -0
- package/dist/lib/bot/claim.d.ts +3 -0
- package/dist/lib/bot/claim.js +20 -0
- package/dist/lib/bot/coordinator.d.ts +27 -0
- package/dist/lib/bot/coordinator.js +178 -0
- package/dist/lib/bot/heartbeat.d.ts +6 -0
- package/dist/lib/bot/heartbeat.js +30 -0
- package/dist/lib/bot/index.d.ts +9 -0
- package/dist/lib/bot/index.js +6 -0
- package/dist/lib/bot/protocol.d.ts +12 -0
- package/dist/lib/bot/protocol.js +74 -0
- package/dist/lib/bot/reporter.d.ts +3 -0
- package/dist/lib/bot/reporter.js +27 -0
- package/dist/lib/bot/skills.d.ts +26 -0
- package/dist/lib/bot/skills.js +69 -0
- package/dist/lib/budget/projections.d.ts +115 -0
- package/dist/lib/budget/projections.js +384 -0
- package/dist/lib/budget/scenarios.d.ts +93 -0
- package/dist/lib/budget/scenarios.js +214 -0
- package/dist/lib/cms/publish-blog.d.ts +62 -0
- package/dist/lib/cms/publish-blog.js +74 -0
- package/dist/lib/cms/strapi-client.d.ts +123 -0
- package/dist/lib/cms/strapi-client.js +213 -0
- package/dist/lib/config/registry.d.ts +17 -0
- package/dist/lib/config/registry.js +182 -0
- package/dist/lib/config/schema.d.ts +31 -0
- package/dist/lib/config/schema.js +25 -0
- package/dist/lib/config.d.ts +55 -0
- package/dist/lib/config.js +206 -0
- package/dist/lib/errors.d.ts +25 -0
- package/dist/lib/errors.js +91 -0
- package/dist/lib/format.d.ts +28 -0
- package/dist/lib/format.js +98 -0
- package/dist/lib/infra/deploy.d.ts +29 -0
- package/dist/lib/infra/deploy.js +58 -0
- package/dist/lib/infra/migrate.d.ts +34 -0
- package/dist/lib/infra/migrate.js +103 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/{lib/newsletter/generate-insurance.ts → dist/lib/newsletter/generate-insurance.d.ts} +7 -24
- package/dist/lib/newsletter/generate-insurance.js +36 -0
- package/dist/lib/newsletter/generate.d.ts +104 -0
- package/dist/lib/newsletter/generate.js +571 -0
- package/dist/lib/returnpro/anomalies.d.ts +64 -0
- package/dist/lib/returnpro/anomalies.js +166 -0
- package/dist/lib/returnpro/audit.d.ts +32 -0
- package/dist/lib/returnpro/audit.js +147 -0
- package/dist/lib/returnpro/diagnose.d.ts +52 -0
- package/dist/lib/returnpro/diagnose.js +281 -0
- package/dist/lib/returnpro/kpis.d.ts +32 -0
- package/dist/lib/returnpro/kpis.js +192 -0
- package/dist/lib/returnpro/templates.d.ts +48 -0
- package/dist/lib/returnpro/templates.js +229 -0
- package/dist/lib/returnpro/upload-income.d.ts +25 -0
- package/dist/lib/returnpro/upload-income.js +235 -0
- package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
- package/dist/lib/returnpro/upload-netsuite.js +566 -0
- package/dist/lib/returnpro/upload-r1.d.ts +48 -0
- package/dist/lib/returnpro/upload-r1.js +398 -0
- package/dist/lib/returnpro/validate.d.ts +37 -0
- package/dist/lib/returnpro/validate.js +124 -0
- package/dist/lib/social/meta.d.ts +90 -0
- package/dist/lib/social/meta.js +160 -0
- package/dist/lib/social/post-generator.d.ts +83 -0
- package/dist/lib/social/post-generator.js +333 -0
- package/dist/lib/social/publish.d.ts +66 -0
- package/dist/lib/social/publish.js +226 -0
- package/dist/lib/social/scraper.d.ts +67 -0
- package/dist/lib/social/scraper.js +361 -0
- package/dist/lib/supabase.d.ts +4 -0
- package/dist/lib/supabase.js +20 -0
- package/dist/lib/transactions/delete-batch.d.ts +60 -0
- package/dist/lib/transactions/delete-batch.js +203 -0
- package/dist/lib/transactions/ingest.d.ts +43 -0
- package/dist/lib/transactions/ingest.js +555 -0
- package/dist/lib/transactions/stamp.d.ts +51 -0
- package/dist/lib/transactions/stamp.js +524 -0
- package/package.json +3 -4
- package/bin/optimal.ts +0 -1731
- package/lib/assets/index.ts +0 -225
- package/lib/assets.ts +0 -124
- package/lib/auth/index.ts +0 -189
- package/lib/board/index.ts +0 -309
- package/lib/board/types.ts +0 -124
- package/lib/bot/claim.ts +0 -43
- package/lib/bot/coordinator.ts +0 -254
- package/lib/bot/heartbeat.ts +0 -37
- package/lib/bot/index.ts +0 -9
- package/lib/bot/protocol.ts +0 -99
- package/lib/bot/reporter.ts +0 -42
- package/lib/bot/skills.ts +0 -81
- package/lib/budget/projections.ts +0 -561
- package/lib/budget/scenarios.ts +0 -312
- package/lib/cms/publish-blog.ts +0 -129
- package/lib/cms/strapi-client.ts +0 -302
- package/lib/config/registry.ts +0 -228
- package/lib/config/schema.ts +0 -58
- package/lib/config.ts +0 -247
- package/lib/errors.ts +0 -129
- package/lib/format.ts +0 -120
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +0 -70
- package/lib/infra/migrate.ts +0 -141
- package/lib/newsletter/.gitkeep +0 -0
- package/lib/newsletter/distribute.ts +0 -256
- package/lib/newsletter/generate.ts +0 -735
- package/lib/returnpro/.gitkeep +0 -0
- package/lib/returnpro/anomalies.ts +0 -258
- package/lib/returnpro/audit.ts +0 -194
- package/lib/returnpro/diagnose.ts +0 -400
- package/lib/returnpro/kpis.ts +0 -255
- package/lib/returnpro/templates.ts +0 -323
- package/lib/returnpro/upload-income.ts +0 -311
- package/lib/returnpro/upload-netsuite.ts +0 -696
- package/lib/returnpro/upload-r1.ts +0 -563
- package/lib/returnpro/validate.ts +0 -154
- package/lib/social/meta.ts +0 -228
- package/lib/social/post-generator.ts +0 -468
- package/lib/social/publish.ts +0 -301
- package/lib/social/scraper.ts +0 -503
- package/lib/supabase.ts +0 -25
- package/lib/transactions/delete-batch.ts +0 -258
- package/lib/transactions/ingest.ts +0 -659
- package/lib/transactions/stamp.ts +0 -654
|
@@ -0,0 +1,1590 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { createProject, getProjectBySlug, listProjects, updateProject, createMilestone, listMilestones, createLabel, listLabels, createTask, updateTask, listTasks, claimTask, addComment, listActivity, formatBoardTable, } from '../lib/board/index.js';
|
|
5
|
+
import { runAuditComparison } from '../lib/returnpro/audit.js';
|
|
6
|
+
import { exportKpis, formatKpiTable, formatKpiCsv } from '../lib/returnpro/kpis.js';
|
|
7
|
+
import { deploy, healthCheck, listApps } from '../lib/infra/deploy.js';
|
|
8
|
+
import { fetchWesImports, parseSummaryFromJson, initializeProjections, applyUniformAdjustment, calculateTotals, exportToCSV, formatProjectionTable, } from '../lib/budget/projections.js';
|
|
9
|
+
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
|
10
|
+
import { generateNewsletter } from '../lib/newsletter/generate.js';
|
|
11
|
+
import { scrapeCompanies, formatCsv } from '../lib/social/scraper.js';
|
|
12
|
+
import { ingestTransactions } from '../lib/transactions/ingest.js';
|
|
13
|
+
import { stampTransactions } from '../lib/transactions/stamp.js';
|
|
14
|
+
import { processR1Upload } from '../lib/returnpro/upload-r1.js';
|
|
15
|
+
import { processNetSuiteUpload } from '../lib/returnpro/upload-netsuite.js';
|
|
16
|
+
import { uploadIncomeStatements } from '../lib/returnpro/upload-income.js';
|
|
17
|
+
import { detectRateAnomalies } from '../lib/returnpro/anomalies.js';
|
|
18
|
+
import { diagnoseMonths } from '../lib/returnpro/diagnose.js';
|
|
19
|
+
import { generateNetSuiteTemplate } from '../lib/returnpro/templates.js';
|
|
20
|
+
import { distributeNewsletter, checkDistributionStatus } from '../lib/newsletter/distribute.js';
|
|
21
|
+
import { generateSocialPosts } from '../lib/social/post-generator.js';
|
|
22
|
+
import { publishSocialPosts, getPublishQueue, retryFailed } from '../lib/social/publish.js';
|
|
23
|
+
import { publishBlog, listBlogDrafts } from '../lib/cms/publish-blog.js';
|
|
24
|
+
import { publishIgPhoto, getMetaConfigForBrand } from '../lib/social/meta.js';
|
|
25
|
+
import { strapiGet, strapiPut } from '../lib/cms/strapi-client.js';
|
|
26
|
+
import { migrateDb, listPendingMigrations, createMigration } from '../lib/infra/migrate.js';
|
|
27
|
+
import { saveScenario, listScenarios, compareScenarios, deleteScenario } from '../lib/budget/scenarios.js';
|
|
28
|
+
import { deleteBatch, previewBatch } from '../lib/transactions/delete-batch.js';
|
|
29
|
+
import { assertOptimalConfigV1 } from '../lib/config/schema.js';
|
|
30
|
+
import { appendHistory, getHistoryPath, getLocalConfigPath, hashConfig, pullRegistryProfile, pushRegistryProfile, readLocalConfig, writeLocalConfig, } from '../lib/config/registry.js';
|
|
31
|
+
import { sendHeartbeat, getActiveAgents, claimNextTask, releaseTask, reportProgress, reportCompletion, reportBlocked, runCoordinatorLoop, getCoordinatorStatus, assignTask, rebalance, } from '../lib/bot/index.js';
|
|
32
|
+
import { colorize, statusBadge, priorityBadge, success, error as fmtError, warn as fmtWarn, info as fmtInfo, } from '../lib/format.js';
|
|
33
|
+
import { listAssets, createAsset, updateAsset, getAsset, deleteAsset, trackAssetUsage, listAssetUsage, formatAssetTable, } from '../lib/assets/index.js';
|
|
34
|
+
const program = new Command()
|
|
35
|
+
.name('optimal')
|
|
36
|
+
.description('Optimal CLI — unified skills for financial analytics, content, and infra')
|
|
37
|
+
.version('0.1.0')
|
|
38
|
+
.addHelpText('after', `
|
|
39
|
+
Examples:
|
|
40
|
+
$ optimal board view View the kanban board
|
|
41
|
+
$ optimal board view -s in_progress Filter board by status
|
|
42
|
+
$ optimal board claim --id <uuid> --agent bot1 Claim a task
|
|
43
|
+
$ optimal project list List all projects
|
|
44
|
+
$ optimal publish-instagram --brand CRE-11TRUST Publish to Instagram
|
|
45
|
+
$ optimal social-queue --brand CRE-11TRUST View social post queue
|
|
46
|
+
$ optimal generate-newsletter --brand CRE-11TRUST Generate a newsletter
|
|
47
|
+
$ optimal audit-financials --months 2025-01 Audit a single month
|
|
48
|
+
$ optimal export-kpis --format csv > kpis.csv Export KPIs as CSV
|
|
49
|
+
$ optimal deploy dashboard --prod Deploy to production
|
|
50
|
+
$ optimal bot agents List active bot agents
|
|
51
|
+
$ optimal config doctor Validate local config
|
|
52
|
+
`);
|
|
53
|
+
// --- Board commands ---
|
|
54
|
+
const board = program.command('board').description('Kanban board operations')
|
|
55
|
+
.addHelpText('after', `
|
|
56
|
+
Examples:
|
|
57
|
+
$ optimal board view Show full board
|
|
58
|
+
$ optimal board view -p cli-consolidation Filter by project
|
|
59
|
+
$ optimal board view -s ready --mine bot1 Show bot1's ready tasks
|
|
60
|
+
$ optimal board create -t "Fix bug" -p cli-consolidation
|
|
61
|
+
$ optimal board claim --id <uuid> --agent bot1
|
|
62
|
+
$ optimal board log --actor bot1 --limit 5
|
|
63
|
+
`);
|
|
64
|
+
board
|
|
65
|
+
.command('view')
|
|
66
|
+
.description('Display the kanban board')
|
|
67
|
+
.option('-p, --project <slug>', 'Project slug')
|
|
68
|
+
.option('-s, --status <status>', 'Filter by status')
|
|
69
|
+
.option('--mine <agent>', 'Show only tasks claimed by agent')
|
|
70
|
+
.action(async (opts) => {
|
|
71
|
+
const filters = {};
|
|
72
|
+
if (opts.project) {
|
|
73
|
+
const proj = await getProjectBySlug(opts.project);
|
|
74
|
+
filters.project_id = proj.id;
|
|
75
|
+
}
|
|
76
|
+
if (opts.status)
|
|
77
|
+
filters.status = opts.status;
|
|
78
|
+
if (opts.mine)
|
|
79
|
+
filters.claimed_by = opts.mine;
|
|
80
|
+
const tasks = await listTasks(filters);
|
|
81
|
+
console.log(formatBoardTable(tasks));
|
|
82
|
+
});
|
|
83
|
+
board
|
|
84
|
+
.command('create')
|
|
85
|
+
.description('Create a new task')
|
|
86
|
+
.addHelpText('after', `
|
|
87
|
+
Example:
|
|
88
|
+
$ optimal board create -t "Migrate auth" -p cli-consolidation --priority 1 --labels infra,migration
|
|
89
|
+
`)
|
|
90
|
+
.requiredOption('-t, --title <title>', 'Task title')
|
|
91
|
+
.requiredOption('-p, --project <slug>', 'Project slug')
|
|
92
|
+
.option('-d, --description <desc>', 'Task description')
|
|
93
|
+
.option('--priority <n>', 'Priority 1-4', '3')
|
|
94
|
+
.option('--skill <ref>', 'Skill reference')
|
|
95
|
+
.option('--source <repo>', 'Source repo')
|
|
96
|
+
.option('--target <module>', 'Target module')
|
|
97
|
+
.option('--effort <size>', 'Effort: xs, s, m, l, xl')
|
|
98
|
+
.option('--blocked-by <ids>', 'Comma-separated blocking task IDs')
|
|
99
|
+
.option('--labels <labels>', 'Comma-separated labels')
|
|
100
|
+
.action(async (opts) => {
|
|
101
|
+
const project = await getProjectBySlug(opts.project);
|
|
102
|
+
const task = await createTask({
|
|
103
|
+
project_id: project.id,
|
|
104
|
+
title: opts.title,
|
|
105
|
+
description: opts.description,
|
|
106
|
+
priority: parseInt(opts.priority),
|
|
107
|
+
skill_required: opts.skill,
|
|
108
|
+
source_repo: opts.source,
|
|
109
|
+
target_module: opts.target,
|
|
110
|
+
estimated_effort: opts.effort,
|
|
111
|
+
blocked_by: opts.blockedBy?.split(',') ?? [],
|
|
112
|
+
labels: opts.labels?.split(',') ?? [],
|
|
113
|
+
});
|
|
114
|
+
success(`Created task: ${colorize(task.id, 'dim')}\n ${task.title} [${statusBadge(task.status)}] ${priorityBadge(task.priority)}`);
|
|
115
|
+
});
|
|
116
|
+
board
|
|
117
|
+
.command('update')
|
|
118
|
+
.description('Update a task')
|
|
119
|
+
.requiredOption('--id <uuid>', 'Task ID')
|
|
120
|
+
.option('-s, --status <status>', 'New status')
|
|
121
|
+
.option('-a, --agent <name>', 'Assign to agent')
|
|
122
|
+
.option('--priority <n>', 'New priority')
|
|
123
|
+
.option('-m, --message <msg>', 'Log message (adds comment)')
|
|
124
|
+
.action(async (opts) => {
|
|
125
|
+
const updates = {};
|
|
126
|
+
if (opts.status)
|
|
127
|
+
updates.status = opts.status;
|
|
128
|
+
if (opts.agent)
|
|
129
|
+
updates.assigned_to = opts.agent;
|
|
130
|
+
if (opts.priority)
|
|
131
|
+
updates.priority = parseInt(opts.priority);
|
|
132
|
+
if (opts.status === 'done')
|
|
133
|
+
updates.completed_at = new Date().toISOString();
|
|
134
|
+
const task = await updateTask(opts.id, updates, opts.agent ?? 'cli');
|
|
135
|
+
if (opts.message)
|
|
136
|
+
await addComment({ task_id: task.id, author: opts.agent ?? 'cli', body: opts.message });
|
|
137
|
+
success(`Updated: ${task.title} -> ${statusBadge(task.status)}`);
|
|
138
|
+
});
|
|
139
|
+
board
|
|
140
|
+
.command('claim')
|
|
141
|
+
.description('Claim a task (bot pull model)')
|
|
142
|
+
.requiredOption('--id <uuid>', 'Task ID')
|
|
143
|
+
.requiredOption('--agent <name>', 'Agent name')
|
|
144
|
+
.action(async (opts) => {
|
|
145
|
+
const task = await claimTask(opts.id, opts.agent);
|
|
146
|
+
success(`Claimed: ${colorize(task.title, 'cyan')} by ${colorize(opts.agent, 'bold')}`);
|
|
147
|
+
});
|
|
148
|
+
board
|
|
149
|
+
.command('comment')
|
|
150
|
+
.description('Add a comment to a task')
|
|
151
|
+
.requiredOption('--id <uuid>', 'Task ID')
|
|
152
|
+
.requiredOption('--author <name>', 'Author name')
|
|
153
|
+
.requiredOption('--body <text>', 'Comment body')
|
|
154
|
+
.action(async (opts) => {
|
|
155
|
+
const comment = await addComment({ task_id: opts.id, author: opts.author, body: opts.body });
|
|
156
|
+
success(`Comment added by ${colorize(comment.author, 'bold')} at ${colorize(comment.created_at, 'dim')}`);
|
|
157
|
+
});
|
|
158
|
+
board
|
|
159
|
+
.command('log')
|
|
160
|
+
.description('View activity log')
|
|
161
|
+
.option('--task <uuid>', 'Filter by task ID')
|
|
162
|
+
.option('--actor <name>', 'Filter by actor')
|
|
163
|
+
.option('--limit <n>', 'Max entries', '20')
|
|
164
|
+
.action(async (opts) => {
|
|
165
|
+
const entries = await listActivity({
|
|
166
|
+
task_id: opts.task,
|
|
167
|
+
actor: opts.actor,
|
|
168
|
+
limit: parseInt(opts.limit),
|
|
169
|
+
});
|
|
170
|
+
for (const e of entries) {
|
|
171
|
+
console.log(`${e.created_at} | ${e.actor.padEnd(8)} | ${e.action.padEnd(15)} | ${JSON.stringify(e.new_value ?? {})}`);
|
|
172
|
+
}
|
|
173
|
+
console.log(`\n${entries.length} entries`);
|
|
174
|
+
});
|
|
175
|
+
// --- Project commands ---
|
|
176
|
+
const proj = program.command('project').description('Project management')
|
|
177
|
+
.addHelpText('after', `
|
|
178
|
+
Examples:
|
|
179
|
+
$ optimal project list
|
|
180
|
+
$ optimal project create --slug my-proj --name "My Project" --priority 1
|
|
181
|
+
$ optimal project update --slug my-proj -s active
|
|
182
|
+
`);
|
|
183
|
+
proj
|
|
184
|
+
.command('list')
|
|
185
|
+
.description('List all projects')
|
|
186
|
+
.action(async () => {
|
|
187
|
+
const projects = await listProjects();
|
|
188
|
+
console.log('| Status | P | Slug | Owner | Name |');
|
|
189
|
+
console.log('|----------|---|-------------------------|---------|------|');
|
|
190
|
+
for (const p of projects) {
|
|
191
|
+
console.log(`| ${p.status.padEnd(8)} | ${p.priority} | ${p.slug.padEnd(23)} | ${(p.owner ?? '—').padEnd(7)} | ${p.name} |`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
proj
|
|
195
|
+
.command('create')
|
|
196
|
+
.description('Create a project')
|
|
197
|
+
.requiredOption('--slug <slug>', 'Project slug')
|
|
198
|
+
.requiredOption('--name <name>', 'Project name')
|
|
199
|
+
.option('--owner <name>', 'Owner')
|
|
200
|
+
.option('--priority <n>', 'Priority 1-4', '3')
|
|
201
|
+
.action(async (opts) => {
|
|
202
|
+
const p = await createProject({
|
|
203
|
+
slug: opts.slug,
|
|
204
|
+
name: opts.name,
|
|
205
|
+
owner: opts.owner,
|
|
206
|
+
priority: parseInt(opts.priority),
|
|
207
|
+
});
|
|
208
|
+
success(`Created project: ${colorize(p.slug, 'cyan')} (${colorize(p.id, 'dim')})`);
|
|
209
|
+
});
|
|
210
|
+
proj
|
|
211
|
+
.command('update')
|
|
212
|
+
.description('Update a project')
|
|
213
|
+
.requiredOption('--slug <slug>', 'Project slug')
|
|
214
|
+
.option('-s, --status <status>', 'New status')
|
|
215
|
+
.option('--owner <name>', 'New owner')
|
|
216
|
+
.action(async (opts) => {
|
|
217
|
+
const updates = {};
|
|
218
|
+
if (opts.status)
|
|
219
|
+
updates.status = opts.status;
|
|
220
|
+
if (opts.owner)
|
|
221
|
+
updates.owner = opts.owner;
|
|
222
|
+
const p = await updateProject(opts.slug, updates);
|
|
223
|
+
success(`Updated project: ${colorize(p.slug, 'cyan')} -> ${statusBadge(p.status)}`);
|
|
224
|
+
});
|
|
225
|
+
// --- Milestone commands ---
|
|
226
|
+
const ms = program.command('milestone').description('Milestone management')
|
|
227
|
+
.addHelpText('after', `
|
|
228
|
+
Examples:
|
|
229
|
+
$ optimal milestone list --project cli-consolidation
|
|
230
|
+
$ optimal milestone create --project cli-consolidation --name "v1.0" --due 2026-04-01
|
|
231
|
+
`);
|
|
232
|
+
ms
|
|
233
|
+
.command('create')
|
|
234
|
+
.description('Create a milestone')
|
|
235
|
+
.requiredOption('--project <slug>', 'Project slug')
|
|
236
|
+
.requiredOption('--name <name>', 'Milestone name')
|
|
237
|
+
.option('--due <date>', 'Due date (YYYY-MM-DD)')
|
|
238
|
+
.action(async (opts) => {
|
|
239
|
+
const project = await getProjectBySlug(opts.project);
|
|
240
|
+
const m = await createMilestone({ project_id: project.id, name: opts.name, due_date: opts.due });
|
|
241
|
+
success(`Created milestone: ${colorize(m.name, 'cyan')} (${colorize(m.id, 'dim')})`);
|
|
242
|
+
});
|
|
243
|
+
ms
|
|
244
|
+
.command('list')
|
|
245
|
+
.description('List milestones')
|
|
246
|
+
.option('--project <slug>', 'Filter by project')
|
|
247
|
+
.action(async (opts) => {
|
|
248
|
+
let projectId;
|
|
249
|
+
if (opts.project) {
|
|
250
|
+
const p = await getProjectBySlug(opts.project);
|
|
251
|
+
projectId = p.id;
|
|
252
|
+
}
|
|
253
|
+
const milestones = await listMilestones(projectId);
|
|
254
|
+
for (const m of milestones) {
|
|
255
|
+
console.log(`${m.status.padEnd(10)} | ${m.due_date ?? 'no date'} | ${m.name}`);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// --- Label commands ---
|
|
259
|
+
const lbl = program.command('label').description('Label management');
|
|
260
|
+
lbl
|
|
261
|
+
.command('create')
|
|
262
|
+
.description('Create a label')
|
|
263
|
+
.requiredOption('--name <name>', 'Label name')
|
|
264
|
+
.option('--color <hex>', 'Color hex code')
|
|
265
|
+
.action(async (opts) => {
|
|
266
|
+
const l = await createLabel(opts.name, opts.color);
|
|
267
|
+
success(`Created label: ${colorize(l.name, 'cyan')} (${colorize(l.id, 'dim')})`);
|
|
268
|
+
});
|
|
269
|
+
lbl
|
|
270
|
+
.command('list')
|
|
271
|
+
.description('List all labels')
|
|
272
|
+
.action(async () => {
|
|
273
|
+
const labels = await listLabels();
|
|
274
|
+
for (const l of labels)
|
|
275
|
+
console.log(`${l.name}${l.color ? ` (${l.color})` : ''}`);
|
|
276
|
+
});
|
|
277
|
+
// Audit financials command
|
|
278
|
+
program
|
|
279
|
+
.command('audit-financials')
|
|
280
|
+
.description('Compare staged financials against confirmed income statements')
|
|
281
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months to audit (default: all)')
|
|
282
|
+
.option('--tolerance <n>', 'Dollar tolerance for match detection', '1.00')
|
|
283
|
+
.action(async (opts) => {
|
|
284
|
+
const months = opts.months
|
|
285
|
+
? opts.months.split(',').map((m) => m.trim())
|
|
286
|
+
: undefined;
|
|
287
|
+
const tolerance = parseFloat(opts.tolerance);
|
|
288
|
+
console.log('Fetching financial data...');
|
|
289
|
+
const result = await runAuditComparison(months, tolerance);
|
|
290
|
+
console.log(`\nStaging rows: ${result.totalStagingRows} | Confirmed rows: ${result.totalConfirmedRows}`);
|
|
291
|
+
console.log(`Tolerance: $${tolerance.toFixed(2)}\n`);
|
|
292
|
+
// Header
|
|
293
|
+
console.log('| Month | Confirmed | Staged | Match | SignFlip | Mismatch | C-Only | S-Only | Accuracy |');
|
|
294
|
+
console.log('|---------|-----------|--------|-------|---------|----------|--------|--------|----------|');
|
|
295
|
+
let flagged = false;
|
|
296
|
+
for (const s of result.summaries) {
|
|
297
|
+
const acc = s.accuracy !== null ? `${s.accuracy}%` : 'N/A';
|
|
298
|
+
const warn = s.accuracy !== null && s.accuracy < 100 ? ' *' : '';
|
|
299
|
+
if (warn)
|
|
300
|
+
flagged = true;
|
|
301
|
+
console.log(`| ${s.month} | ${String(s.confirmedAccounts).padStart(9)} | ${String(s.stagedAccounts).padStart(6)} | ${String(s.exactMatch).padStart(5)} | ${String(s.signFlipMatch).padStart(7)} | ${String(s.mismatch).padStart(8)} | ${String(s.confirmedOnly).padStart(6)} | ${String(s.stagingOnly).padStart(6)} | ${(acc + warn).padStart(8)} |`);
|
|
302
|
+
}
|
|
303
|
+
if (flagged) {
|
|
304
|
+
console.log('\n* Months below 100% accuracy — investigate mismatches');
|
|
305
|
+
}
|
|
306
|
+
// Totals row
|
|
307
|
+
if (result.summaries.length > 1) {
|
|
308
|
+
const totals = result.summaries.reduce((acc, s) => ({
|
|
309
|
+
confirmed: acc.confirmed + s.confirmedAccounts,
|
|
310
|
+
staged: acc.staged + s.stagedAccounts,
|
|
311
|
+
exact: acc.exact + s.exactMatch,
|
|
312
|
+
flip: acc.flip + s.signFlipMatch,
|
|
313
|
+
mismatch: acc.mismatch + s.mismatch,
|
|
314
|
+
cOnly: acc.cOnly + s.confirmedOnly,
|
|
315
|
+
sOnly: acc.sOnly + s.stagingOnly,
|
|
316
|
+
}), { confirmed: 0, staged: 0, exact: 0, flip: 0, mismatch: 0, cOnly: 0, sOnly: 0 });
|
|
317
|
+
const totalOverlap = totals.exact + totals.flip + totals.mismatch;
|
|
318
|
+
const totalAcc = totalOverlap > 0
|
|
319
|
+
? Math.round(((totals.exact + totals.flip) / totalOverlap) * 1000) / 10
|
|
320
|
+
: null;
|
|
321
|
+
console.log(`| TOTAL | ${String(totals.confirmed).padStart(9)} | ${String(totals.staged).padStart(6)} | ${String(totals.exact).padStart(5)} | ${String(totals.flip).padStart(7)} | ${String(totals.mismatch).padStart(8)} | ${String(totals.cOnly).padStart(6)} | ${String(totals.sOnly).padStart(6)} | ${(totalAcc !== null ? `${totalAcc}%` : 'N/A').padStart(8)} |`);
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
// Export KPIs command
|
|
325
|
+
program
|
|
326
|
+
.command('export-kpis')
|
|
327
|
+
.description('Export KPI totals by program/client from ReturnPro financial data')
|
|
328
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months (default: 3 most recent)')
|
|
329
|
+
.option('--programs <csv>', 'Comma-separated program name substrings to filter')
|
|
330
|
+
.option('--format <fmt>', 'Output format: table or csv', 'table')
|
|
331
|
+
.action(async (opts) => {
|
|
332
|
+
const months = opts.months
|
|
333
|
+
? opts.months.split(',').map((m) => m.trim())
|
|
334
|
+
: undefined;
|
|
335
|
+
const programs = opts.programs
|
|
336
|
+
? opts.programs.split(',').map((p) => p.trim())
|
|
337
|
+
: undefined;
|
|
338
|
+
const format = opts.format;
|
|
339
|
+
if (format !== 'table' && format !== 'csv') {
|
|
340
|
+
console.error(`Invalid format "${format}". Use "table" or "csv".`);
|
|
341
|
+
process.exit(1);
|
|
342
|
+
}
|
|
343
|
+
console.error('Fetching KPI data...');
|
|
344
|
+
const rows = await exportKpis({ months, programs });
|
|
345
|
+
console.error(`Fetched ${rows.length} KPI rows`);
|
|
346
|
+
if (format === 'csv') {
|
|
347
|
+
console.log(formatKpiCsv(rows));
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
console.log(formatKpiTable(rows));
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
// Deploy command
|
|
354
|
+
program
|
|
355
|
+
.command('deploy')
|
|
356
|
+
.description('Deploy an app to Vercel (preview or production)')
|
|
357
|
+
.argument('<app>', `App to deploy (${listApps().join(', ')})`)
|
|
358
|
+
.option('--prod', 'Deploy to production', false)
|
|
359
|
+
.action(async (app, opts) => {
|
|
360
|
+
fmtInfo(`Deploying ${colorize(app, 'cyan')}${opts.prod ? colorize(' (production)', 'yellow') : ' (preview)'}...`);
|
|
361
|
+
try {
|
|
362
|
+
const url = await deploy(app, opts.prod);
|
|
363
|
+
success(`Deployed: ${colorize(url, 'green')}`);
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
367
|
+
fmtError(`Deploy failed: ${msg}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
// Health check command
|
|
372
|
+
program
|
|
373
|
+
.command('health-check')
|
|
374
|
+
.description('Run health check across all Optimal services')
|
|
375
|
+
.action(async () => {
|
|
376
|
+
try {
|
|
377
|
+
const output = await healthCheck();
|
|
378
|
+
console.log(output);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
381
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
382
|
+
console.error(`Health check failed: ${msg}`);
|
|
383
|
+
process.exit(1);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
// Budget projection commands
|
|
387
|
+
async function loadProjectionData(opts) {
|
|
388
|
+
if (opts.file) {
|
|
389
|
+
const raw = readFileSync(opts.file, 'utf-8');
|
|
390
|
+
return parseSummaryFromJson(raw);
|
|
391
|
+
}
|
|
392
|
+
const fy = opts.fiscalYear ? parseInt(opts.fiscalYear) : 2025;
|
|
393
|
+
return fetchWesImports({ fiscalYear: fy, userId: opts.userId });
|
|
394
|
+
}
|
|
395
|
+
function resolveAdjustmentType(raw) {
|
|
396
|
+
if (raw === 'flat')
|
|
397
|
+
return 'flat';
|
|
398
|
+
return 'percentage';
|
|
399
|
+
}
|
|
400
|
+
program
|
|
401
|
+
.command('project-budget')
|
|
402
|
+
.description('Run FY26 budget projections with adjustments on FY25 checked-in units')
|
|
403
|
+
.option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
|
|
404
|
+
.option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
|
|
405
|
+
.option('--format <fmt>', 'Output format: table or csv', 'table')
|
|
406
|
+
.option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
|
|
407
|
+
.option('--user-id <uuid>', 'Supabase user UUID to filter by')
|
|
408
|
+
.option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
|
|
409
|
+
.action(async (opts) => {
|
|
410
|
+
const format = opts.format;
|
|
411
|
+
if (format !== 'table' && format !== 'csv') {
|
|
412
|
+
console.error(`Invalid format "${format}". Use "table" or "csv".`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
console.error('Loading projection data...');
|
|
416
|
+
const summary = await loadProjectionData(opts);
|
|
417
|
+
console.error(`Loaded ${summary.length} programs`);
|
|
418
|
+
let projections = initializeProjections(summary);
|
|
419
|
+
const adjType = resolveAdjustmentType(opts.adjustmentType);
|
|
420
|
+
const adjValue = parseFloat(opts.adjustmentValue);
|
|
421
|
+
if (adjValue !== 0) {
|
|
422
|
+
projections = applyUniformAdjustment(projections, adjType, adjValue);
|
|
423
|
+
console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
|
|
424
|
+
}
|
|
425
|
+
const totals = calculateTotals(projections);
|
|
426
|
+
console.error(`Totals: ${totals.totalActual} actual -> ${totals.totalProjected} projected (${totals.percentageChange >= 0 ? '+' : ''}${totals.percentageChange.toFixed(1)}%)`);
|
|
427
|
+
if (format === 'csv') {
|
|
428
|
+
console.log(exportToCSV(projections));
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
console.log(formatProjectionTable(projections));
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
program
|
|
435
|
+
.command('export-budget')
|
|
436
|
+
.description('Export FY26 budget projections as CSV')
|
|
437
|
+
.option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
|
|
438
|
+
.option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
|
|
439
|
+
.option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
|
|
440
|
+
.option('--user-id <uuid>', 'Supabase user UUID to filter by')
|
|
441
|
+
.option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
|
|
442
|
+
.action(async (opts) => {
|
|
443
|
+
console.error('Loading projection data...');
|
|
444
|
+
const summary = await loadProjectionData(opts);
|
|
445
|
+
console.error(`Loaded ${summary.length} programs`);
|
|
446
|
+
let projections = initializeProjections(summary);
|
|
447
|
+
const adjType = resolveAdjustmentType(opts.adjustmentType);
|
|
448
|
+
const adjValue = parseFloat(opts.adjustmentValue);
|
|
449
|
+
if (adjValue !== 0) {
|
|
450
|
+
projections = applyUniformAdjustment(projections, adjType, adjValue);
|
|
451
|
+
console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
|
|
452
|
+
}
|
|
453
|
+
console.log(exportToCSV(projections));
|
|
454
|
+
});
|
|
455
|
+
// Newsletter generation command
|
|
456
|
+
program
|
|
457
|
+
.command('generate-newsletter')
|
|
458
|
+
.description('Generate a branded newsletter with AI content and push to Strapi CMS')
|
|
459
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
460
|
+
.option('--date <date>', 'Edition date as YYYY-MM-DD (default: today)')
|
|
461
|
+
.option('--excel <path>', 'Path to Excel file with property listings (CRE-11TRUST only)')
|
|
462
|
+
.option('--dry-run', 'Generate content but do NOT push to Strapi', false)
|
|
463
|
+
.action(async (opts) => {
|
|
464
|
+
try {
|
|
465
|
+
const result = await generateNewsletter({
|
|
466
|
+
brand: opts.brand,
|
|
467
|
+
date: opts.date,
|
|
468
|
+
excelPath: opts.excel,
|
|
469
|
+
dryRun: opts.dryRun,
|
|
470
|
+
});
|
|
471
|
+
if (result.strapiDocumentId) {
|
|
472
|
+
success(`Strapi documentId: ${colorize(result.strapiDocumentId, 'cyan')}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch (err) {
|
|
476
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
477
|
+
fmtError(`Newsletter generation failed: ${msg}`);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// Scrape Meta Ad Library command
|
|
482
|
+
program
|
|
483
|
+
.command('scrape-ads')
|
|
484
|
+
.description('Scrape Meta Ad Library for competitor ad intelligence')
|
|
485
|
+
.requiredOption('--companies <csv-or-file>', 'Comma-separated company names or path to a text file (one per line)')
|
|
486
|
+
.option('--output <path>', 'Save CSV results to file (default: stdout)')
|
|
487
|
+
.option('--batch-size <n>', 'Companies per batch', '6')
|
|
488
|
+
.action(async (opts) => {
|
|
489
|
+
// Parse companies: file path or comma-separated list
|
|
490
|
+
let companies;
|
|
491
|
+
if (existsSync(opts.companies)) {
|
|
492
|
+
const raw = readFileSync(opts.companies, 'utf-8');
|
|
493
|
+
companies = raw
|
|
494
|
+
.split('\n')
|
|
495
|
+
.map((l) => l.trim())
|
|
496
|
+
.filter((l) => l.length > 0 && !l.startsWith('#'));
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
companies = opts.companies
|
|
500
|
+
.split(',')
|
|
501
|
+
.map((c) => c.trim())
|
|
502
|
+
.filter((c) => c.length > 0);
|
|
503
|
+
}
|
|
504
|
+
if (companies.length === 0) {
|
|
505
|
+
console.error('No companies specified');
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
const batchSize = parseInt(opts.batchSize);
|
|
509
|
+
if (isNaN(batchSize) || batchSize < 1) {
|
|
510
|
+
console.error('Invalid batch size');
|
|
511
|
+
process.exit(1);
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
const result = await scrapeCompanies({
|
|
515
|
+
companies,
|
|
516
|
+
outputPath: opts.output,
|
|
517
|
+
batchSize,
|
|
518
|
+
});
|
|
519
|
+
// If no output file, write CSV to stdout
|
|
520
|
+
if (!opts.output) {
|
|
521
|
+
process.stdout.write(formatCsv(result.ads));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
526
|
+
console.error(`Scrape failed: ${msg}`);
|
|
527
|
+
process.exit(1);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
// Ingest transactions command
|
|
531
|
+
program
|
|
532
|
+
.command('ingest-transactions')
|
|
533
|
+
.description('Parse & deduplicate bank CSV files into the transactions table')
|
|
534
|
+
.requiredOption('--file <path>', 'Path to the CSV file')
|
|
535
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
536
|
+
.action(async (opts) => {
|
|
537
|
+
if (!existsSync(opts.file)) {
|
|
538
|
+
console.error(`File not found: ${opts.file}`);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
}
|
|
541
|
+
console.log(`Ingesting transactions from: ${opts.file}`);
|
|
542
|
+
try {
|
|
543
|
+
const result = await ingestTransactions(opts.file, opts.userId);
|
|
544
|
+
console.log(`\nFormat detected: ${result.format}`);
|
|
545
|
+
console.log(`Inserted: ${result.inserted} | Skipped (duplicates): ${result.skipped} | Failed: ${result.failed}`);
|
|
546
|
+
if (result.errors.length > 0) {
|
|
547
|
+
console.log(`\nWarnings/Errors (${result.errors.length}):`);
|
|
548
|
+
for (const err of result.errors.slice(0, 20)) {
|
|
549
|
+
console.log(` - ${err}`);
|
|
550
|
+
}
|
|
551
|
+
if (result.errors.length > 20) {
|
|
552
|
+
console.log(` ... and ${result.errors.length - 20} more`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
catch (err) {
|
|
557
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
558
|
+
console.error(`Ingest failed: ${msg}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
// Stamp transactions command
|
|
563
|
+
program
|
|
564
|
+
.command('stamp-transactions')
|
|
565
|
+
.description('Auto-categorize unclassified transactions using rule-based matching')
|
|
566
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
567
|
+
.option('--dry-run', 'Preview matches without writing to database', false)
|
|
568
|
+
.action(async (opts) => {
|
|
569
|
+
console.log(`Stamping transactions for user: ${opts.userId}${opts.dryRun ? ' (DRY RUN)' : ''}`);
|
|
570
|
+
try {
|
|
571
|
+
const result = await stampTransactions(opts.userId, { dryRun: opts.dryRun });
|
|
572
|
+
console.log(`\nTotal unclassified: ${result.total}`);
|
|
573
|
+
console.log(`Stamped: ${result.stamped} | Unmatched: ${result.unmatched}`);
|
|
574
|
+
console.log(`By match type: PATTERN=${result.byMatchType.PATTERN}, LEARNED=${result.byMatchType.LEARNED}, EXACT=${result.byMatchType.EXACT}, FUZZY=${result.byMatchType.FUZZY}, CATEGORY_INFER=${result.byMatchType.CATEGORY_INFER}`);
|
|
575
|
+
if (result.dryRun) {
|
|
576
|
+
console.log('\n(Dry run — no database changes made)');
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
catch (err) {
|
|
580
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
581
|
+
console.error(`Stamp failed: ${msg}`);
|
|
582
|
+
process.exit(1);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
// ── Upload R1 data ──────────────────────────────────────────────────
|
|
586
|
+
program
|
|
587
|
+
.command('upload-r1')
|
|
588
|
+
.description('Upload R1 XLSX file to ReturnPro staging')
|
|
589
|
+
.requiredOption('--file <path>', 'Path to R1 XLSX file')
|
|
590
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
591
|
+
.requiredOption('--month <YYYY-MM>', 'Month in YYYY-MM format')
|
|
592
|
+
.action(async (opts) => {
|
|
593
|
+
if (!existsSync(opts.file)) {
|
|
594
|
+
console.error(`File not found: ${opts.file}`);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
}
|
|
597
|
+
try {
|
|
598
|
+
const result = await processR1Upload(opts.file, opts.userId, opts.month);
|
|
599
|
+
console.log(`R1 upload complete: ${result.rowsInserted} rows inserted, ${result.rowsSkipped} skipped (${result.programGroupsFound} program groups)`);
|
|
600
|
+
if (result.warnings.length > 0) {
|
|
601
|
+
console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
console.error(`R1 upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
606
|
+
process.exit(1);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
// ── Upload NetSuite data ────────────────────────────────────────────
|
|
610
|
+
program
|
|
611
|
+
.command('upload-netsuite')
|
|
612
|
+
.description('Upload NetSuite CSV/XLSX to ReturnPro staging')
|
|
613
|
+
.requiredOption('--file <path>', 'Path to NetSuite file (CSV, XLSX, or XLSM)')
|
|
614
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
615
|
+
.action(async (opts) => {
|
|
616
|
+
if (!existsSync(opts.file)) {
|
|
617
|
+
console.error(`File not found: ${opts.file}`);
|
|
618
|
+
process.exit(1);
|
|
619
|
+
}
|
|
620
|
+
try {
|
|
621
|
+
const result = await processNetSuiteUpload(opts.file, opts.userId);
|
|
622
|
+
console.log(`NetSuite upload: ${result.inserted} rows inserted (months: ${result.monthsCovered.join(', ')})`);
|
|
623
|
+
if (result.warnings.length > 0) {
|
|
624
|
+
console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
console.error(`NetSuite upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
629
|
+
process.exit(1);
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
// ── Upload income statements ────────────────────────────────────────
|
|
633
|
+
program
|
|
634
|
+
.command('upload-income-statements')
|
|
635
|
+
.description('Upload confirmed income statement CSV to ReturnPro')
|
|
636
|
+
.requiredOption('--file <path>', 'Path to income statement CSV')
|
|
637
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
638
|
+
.action(async (opts) => {
|
|
639
|
+
if (!existsSync(opts.file)) {
|
|
640
|
+
console.error(`File not found: ${opts.file}`);
|
|
641
|
+
process.exit(1);
|
|
642
|
+
}
|
|
643
|
+
try {
|
|
644
|
+
const result = await uploadIncomeStatements(opts.file, opts.userId);
|
|
645
|
+
console.log(`Income statements: ${result.upserted} rows upserted, ${result.skipped} skipped (period: ${result.period})`);
|
|
646
|
+
}
|
|
647
|
+
catch (err) {
|
|
648
|
+
console.error(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
649
|
+
process.exit(1);
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
// ── Rate anomalies ──────────────────────────────────────────────────
|
|
653
|
+
program
|
|
654
|
+
.command('rate-anomalies')
|
|
655
|
+
.description('Detect rate anomalies via z-score analysis on ReturnPro data')
|
|
656
|
+
.option('--from <YYYY-MM>', 'Start month')
|
|
657
|
+
.option('--to <YYYY-MM>', 'End month')
|
|
658
|
+
.option('--threshold <n>', 'Z-score threshold', '2.0')
|
|
659
|
+
.action(async (opts) => {
|
|
660
|
+
try {
|
|
661
|
+
const months = opts.from && opts.to
|
|
662
|
+
? (() => {
|
|
663
|
+
const result = [];
|
|
664
|
+
const [fy, fm] = opts.from.split('-').map(Number);
|
|
665
|
+
const [ty, tm] = opts.to.split('-').map(Number);
|
|
666
|
+
let y = fy, m = fm;
|
|
667
|
+
while (y < ty || (y === ty && m <= tm)) {
|
|
668
|
+
result.push(`${y}-${String(m).padStart(2, '0')}`);
|
|
669
|
+
m++;
|
|
670
|
+
if (m > 12) {
|
|
671
|
+
m = 1;
|
|
672
|
+
y++;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return result;
|
|
676
|
+
})()
|
|
677
|
+
: undefined;
|
|
678
|
+
const result = await detectRateAnomalies({
|
|
679
|
+
months,
|
|
680
|
+
threshold: parseFloat(opts.threshold),
|
|
681
|
+
});
|
|
682
|
+
console.log(`Found ${result.anomalies.length} anomalies (threshold: ${opts.threshold}σ)`);
|
|
683
|
+
for (const a of result.anomalies.slice(0, 30)) {
|
|
684
|
+
console.log(` ${a.month} | ${a.program_code ?? a.master_program} | z=${a.zscore.toFixed(2)} | rate=${a.rate_per_unit}`);
|
|
685
|
+
}
|
|
686
|
+
if (result.anomalies.length > 30)
|
|
687
|
+
console.log(` ... and ${result.anomalies.length - 30} more`);
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
console.error(`Anomaly detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
691
|
+
process.exit(1);
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
// ── Diagnose months ─────────────────────────────────────────────────
|
|
695
|
+
program
|
|
696
|
+
.command('diagnose-months')
|
|
697
|
+
.description('Run diagnostic checks on staging data for specified months')
|
|
698
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months (default: all)')
|
|
699
|
+
.action(async (opts) => {
|
|
700
|
+
const months = opts.months?.split(',').map(m => m.trim());
|
|
701
|
+
try {
|
|
702
|
+
const result = await diagnoseMonths(months ? { months } : undefined);
|
|
703
|
+
console.log(`Analysed months: ${result.monthsAnalysed.join(', ')}`);
|
|
704
|
+
console.log(`Total staging rows: ${result.totalRows} (median: ${result.medianRowCount}/month)\n`);
|
|
705
|
+
for (const issue of result.issues) {
|
|
706
|
+
console.log(` ✗ [${issue.kind}] ${issue.month ?? 'global'}: ${issue.message}`);
|
|
707
|
+
}
|
|
708
|
+
if (result.issues.length === 0) {
|
|
709
|
+
console.log(' ✓ No issues found');
|
|
710
|
+
}
|
|
711
|
+
console.log(`\nSummary: ${result.summary.totalIssues} issues found`);
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
console.error(`Diagnosis failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
715
|
+
process.exit(1);
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
// ── Generate NetSuite template ──────────────────────────────────────
|
|
719
|
+
program
|
|
720
|
+
.command('generate-netsuite-template')
|
|
721
|
+
.description('Generate a blank NetSuite XLSX upload template')
|
|
722
|
+
.option('--output <path>', 'Output file path', 'netsuite-template.xlsx')
|
|
723
|
+
.action(async (opts) => {
|
|
724
|
+
try {
|
|
725
|
+
const result = await generateNetSuiteTemplate(opts.output);
|
|
726
|
+
console.log(`Template saved: ${result.outputPath} (${result.accountCount} accounts)`);
|
|
727
|
+
}
|
|
728
|
+
catch (err) {
|
|
729
|
+
console.error(`Template generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
730
|
+
process.exit(1);
|
|
731
|
+
}
|
|
732
|
+
});
|
|
733
|
+
// ── Distribute newsletter ───────────────────────────────────────────
|
|
734
|
+
program
|
|
735
|
+
.command('distribute-newsletter')
|
|
736
|
+
.description('Trigger newsletter distribution via n8n webhook')
|
|
737
|
+
.requiredOption('--document-id <id>', 'Strapi newsletter documentId')
|
|
738
|
+
.option('--channel <ch>', 'Distribution channel: email or all', 'all')
|
|
739
|
+
.action(async (opts) => {
|
|
740
|
+
try {
|
|
741
|
+
const result = await distributeNewsletter(opts.documentId, {
|
|
742
|
+
channel: opts.channel,
|
|
743
|
+
});
|
|
744
|
+
if (result.success) {
|
|
745
|
+
console.log(`Distribution triggered for ${opts.documentId} (channel: ${opts.channel})`);
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
console.error(`Distribution failed: ${result.error}`);
|
|
749
|
+
process.exit(1);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
catch (err) {
|
|
753
|
+
console.error(`Distribution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
754
|
+
process.exit(1);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
// ── Check distribution status ───────────────────────────────────────
|
|
758
|
+
program
|
|
759
|
+
.command('distribution-status')
|
|
760
|
+
.description('Check delivery status of a newsletter')
|
|
761
|
+
.requiredOption('--document-id <id>', 'Strapi newsletter documentId')
|
|
762
|
+
.action(async (opts) => {
|
|
763
|
+
const status = await checkDistributionStatus(opts.documentId);
|
|
764
|
+
console.log(`Status: ${status.delivery_status}`);
|
|
765
|
+
if (status.delivered_at)
|
|
766
|
+
console.log(`Delivered: ${status.delivered_at}`);
|
|
767
|
+
if (status.recipients_count)
|
|
768
|
+
console.log(`Recipients: ${status.recipients_count}`);
|
|
769
|
+
if (status.ghl_campaign_id)
|
|
770
|
+
console.log(`GHL Campaign: ${status.ghl_campaign_id}`);
|
|
771
|
+
});
|
|
772
|
+
// ── Generate social posts ───────────────────────────────────────────
|
|
773
|
+
program
|
|
774
|
+
.command('generate-social-posts')
|
|
775
|
+
.description('Generate AI-powered social media ad posts and push to Strapi')
|
|
776
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
777
|
+
.option('--count <n>', 'Number of posts to generate', '9')
|
|
778
|
+
.option('--week-of <date>', 'Week start date YYYY-MM-DD (default: next Monday)')
|
|
779
|
+
.option('--dry-run', 'Generate without pushing to Strapi', false)
|
|
780
|
+
.action(async (opts) => {
|
|
781
|
+
try {
|
|
782
|
+
const result = await generateSocialPosts({
|
|
783
|
+
brand: opts.brand,
|
|
784
|
+
count: parseInt(opts.count),
|
|
785
|
+
weekOf: opts.weekOf,
|
|
786
|
+
dryRun: opts.dryRun,
|
|
787
|
+
});
|
|
788
|
+
console.log(`Created ${result.postsCreated} posts for ${result.brand}`);
|
|
789
|
+
for (const p of result.posts) {
|
|
790
|
+
console.log(` ${p.scheduled_date} | ${p.platform} | ${p.headline}`);
|
|
791
|
+
}
|
|
792
|
+
if (result.errors.length > 0) {
|
|
793
|
+
console.log(`\nErrors: ${result.errors.join(', ')}`);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
catch (err) {
|
|
797
|
+
console.error(`Post generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
798
|
+
process.exit(1);
|
|
799
|
+
}
|
|
800
|
+
});
|
|
801
|
+
// ── Publish social posts ────────────────────────────────────────────
|
|
802
|
+
program
|
|
803
|
+
.command('publish-social-posts')
|
|
804
|
+
.description('Publish pending social posts to platforms via n8n')
|
|
805
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
806
|
+
.option('--limit <n>', 'Max posts to publish')
|
|
807
|
+
.option('--dry-run', 'Preview without publishing', false)
|
|
808
|
+
.option('--retry', 'Retry previously failed posts', false)
|
|
809
|
+
.action(async (opts) => {
|
|
810
|
+
try {
|
|
811
|
+
let result;
|
|
812
|
+
if (opts.retry) {
|
|
813
|
+
result = await retryFailed(opts.brand);
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
result = await publishSocialPosts({
|
|
817
|
+
brand: opts.brand,
|
|
818
|
+
limit: opts.limit ? parseInt(opts.limit) : undefined,
|
|
819
|
+
dryRun: opts.dryRun,
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
console.log(`Published: ${result.published} | Failed: ${result.failed} | Skipped: ${result.skipped}`);
|
|
823
|
+
for (const d of result.details) {
|
|
824
|
+
console.log(` ${d.status} | ${d.headline}${d.error ? ` — ${d.error}` : ''}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
catch (err) {
|
|
828
|
+
console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
829
|
+
process.exit(1);
|
|
830
|
+
}
|
|
831
|
+
});
|
|
832
|
+
// ── Social post queue ───────────────────────────────────────────────
|
|
833
|
+
program
|
|
834
|
+
.command('social-queue')
|
|
835
|
+
.description('View pending social posts ready for publishing')
|
|
836
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
837
|
+
.action(async (opts) => {
|
|
838
|
+
const queue = await getPublishQueue(opts.brand);
|
|
839
|
+
if (queue.length === 0) {
|
|
840
|
+
console.log('No posts in queue');
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
console.log('| Date | Platform | Headline |');
|
|
844
|
+
console.log('|------|----------|----------|');
|
|
845
|
+
for (const p of queue) {
|
|
846
|
+
console.log(`| ${p.scheduled_date} | ${p.platform} | ${p.headline} |`);
|
|
847
|
+
}
|
|
848
|
+
console.log(`\n${queue.length} posts queued`);
|
|
849
|
+
});
|
|
850
|
+
// ── Publish to Instagram via Meta Graph API ─────────────────────────
|
|
851
|
+
program
|
|
852
|
+
.command('publish-instagram')
|
|
853
|
+
.description('Publish pending social posts to Instagram via Meta Graph API')
|
|
854
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
855
|
+
.option('--limit <n>', 'Max posts to publish')
|
|
856
|
+
.option('--dry-run', 'Preview without publishing', false)
|
|
857
|
+
.action(async (opts) => {
|
|
858
|
+
try {
|
|
859
|
+
const config = getMetaConfigForBrand(opts.brand);
|
|
860
|
+
// Fetch pending instagram posts from Strapi
|
|
861
|
+
const result = await strapiGet('/api/social-posts', {
|
|
862
|
+
'filters[brand][$eq]': opts.brand,
|
|
863
|
+
'filters[delivery_status][$eq]': 'pending',
|
|
864
|
+
'filters[platform][$eq]': 'instagram',
|
|
865
|
+
'sort': 'scheduled_date:asc',
|
|
866
|
+
'pagination[pageSize]': opts.limit ?? '50',
|
|
867
|
+
});
|
|
868
|
+
const posts = result.data;
|
|
869
|
+
if (posts.length === 0) {
|
|
870
|
+
console.log('No pending Instagram posts found');
|
|
871
|
+
return;
|
|
872
|
+
}
|
|
873
|
+
console.log(`Found ${posts.length} pending Instagram post(s) for ${opts.brand}`);
|
|
874
|
+
let published = 0;
|
|
875
|
+
let failed = 0;
|
|
876
|
+
for (const post of posts) {
|
|
877
|
+
const headline = post.headline ?? '(no headline)';
|
|
878
|
+
const imageUrl = post.image_url;
|
|
879
|
+
const caption = (post.body ?? post.headline ?? '').trim();
|
|
880
|
+
if (!imageUrl) {
|
|
881
|
+
console.log(` SKIP | ${headline} — no image_url`);
|
|
882
|
+
failed++;
|
|
883
|
+
continue;
|
|
884
|
+
}
|
|
885
|
+
if (opts.dryRun) {
|
|
886
|
+
console.log(` DRY | ${headline}`);
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
try {
|
|
890
|
+
const igResult = await publishIgPhoto(config, { imageUrl, caption });
|
|
891
|
+
await strapiPut('/api/social-posts', post.documentId, {
|
|
892
|
+
delivery_status: 'delivered',
|
|
893
|
+
platform_post_id: igResult.mediaId,
|
|
894
|
+
});
|
|
895
|
+
console.log(` OK | ${headline} → ${igResult.mediaId}`);
|
|
896
|
+
published++;
|
|
897
|
+
}
|
|
898
|
+
catch (err) {
|
|
899
|
+
const errMsg = err instanceof Error ? err.message : String(err);
|
|
900
|
+
await strapiPut('/api/social-posts', post.documentId, {
|
|
901
|
+
delivery_status: 'failed',
|
|
902
|
+
delivery_errors: [{ timestamp: new Date().toISOString(), error: errMsg }],
|
|
903
|
+
}).catch(() => { });
|
|
904
|
+
console.log(` FAIL | ${headline} — ${errMsg}`);
|
|
905
|
+
failed++;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
console.log(`\nPublished: ${published} | Failed: ${failed}${opts.dryRun ? ' | (dry run)' : ''}`);
|
|
909
|
+
}
|
|
910
|
+
catch (err) {
|
|
911
|
+
console.error(`Instagram publish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
912
|
+
process.exit(1);
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
// ── Publish blog ────────────────────────────────────────────────────
|
|
916
|
+
program
|
|
917
|
+
.command('publish-blog')
|
|
918
|
+
.description('Publish a Strapi blog post and optionally deploy portfolio site')
|
|
919
|
+
.requiredOption('--slug <slug>', 'Blog post slug')
|
|
920
|
+
.option('--deploy', 'Deploy portfolio site after publishing', false)
|
|
921
|
+
.action(async (opts) => {
|
|
922
|
+
try {
|
|
923
|
+
const result = await publishBlog({ slug: opts.slug, deployAfter: opts.deploy });
|
|
924
|
+
console.log(`Published: ${result.slug} (${result.documentId})`);
|
|
925
|
+
if (result.deployUrl)
|
|
926
|
+
console.log(`Deployed: ${result.deployUrl}`);
|
|
927
|
+
}
|
|
928
|
+
catch (err) {
|
|
929
|
+
console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
930
|
+
process.exit(1);
|
|
931
|
+
}
|
|
932
|
+
});
|
|
933
|
+
// ── Blog drafts ─────────────────────────────────────────────────────
|
|
934
|
+
program
|
|
935
|
+
.command('blog-drafts')
|
|
936
|
+
.description('List unpublished blog post drafts')
|
|
937
|
+
.option('--site <site>', 'Filter by site (portfolio, insurance)')
|
|
938
|
+
.action(async (opts) => {
|
|
939
|
+
const drafts = await listBlogDrafts(opts.site);
|
|
940
|
+
if (drafts.length === 0) {
|
|
941
|
+
console.log('No drafts found');
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
console.log('| Created | Site | Title | Slug |');
|
|
945
|
+
console.log('|---------|------|-------|------|');
|
|
946
|
+
for (const d of drafts) {
|
|
947
|
+
console.log(`| ${d.createdAt.slice(0, 10)} | ${d.site} | ${d.title} | ${d.slug} |`);
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
// ── Database migration ──────────────────────────────────────────────
|
|
951
|
+
const migrate = program.command('migrate').description('Supabase database migration operations')
|
|
952
|
+
.addHelpText('after', `
|
|
953
|
+
Examples:
|
|
954
|
+
$ optimal migrate pending --target optimalos
|
|
955
|
+
$ optimal migrate push --target returnpro --dry-run
|
|
956
|
+
$ optimal migrate create --target optimalos --name "add-index"
|
|
957
|
+
`);
|
|
958
|
+
migrate
|
|
959
|
+
.command('push')
|
|
960
|
+
.description('Run supabase db push --linked on a target project')
|
|
961
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
962
|
+
.option('--dry-run', 'Preview without applying', false)
|
|
963
|
+
.action(async (opts) => {
|
|
964
|
+
const target = opts.target;
|
|
965
|
+
if (target !== 'returnpro' && target !== 'optimalos') {
|
|
966
|
+
console.error('Target must be "returnpro" or "optimalos"');
|
|
967
|
+
process.exit(1);
|
|
968
|
+
}
|
|
969
|
+
console.log(`Migrating ${target}${opts.dryRun ? ' (dry run)' : ''}...`);
|
|
970
|
+
const result = await migrateDb({ target, dryRun: opts.dryRun });
|
|
971
|
+
if (result.success) {
|
|
972
|
+
console.log(result.output);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
console.error(`Migration failed:\n${result.errors}`);
|
|
976
|
+
process.exit(1);
|
|
977
|
+
}
|
|
978
|
+
});
|
|
979
|
+
migrate
|
|
980
|
+
.command('pending')
|
|
981
|
+
.description('List pending migration files')
|
|
982
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
983
|
+
.action(async (opts) => {
|
|
984
|
+
const files = await listPendingMigrations(opts.target);
|
|
985
|
+
if (files.length === 0) {
|
|
986
|
+
console.log('No migration files found');
|
|
987
|
+
return;
|
|
988
|
+
}
|
|
989
|
+
for (const f of files)
|
|
990
|
+
console.log(` ${f}`);
|
|
991
|
+
console.log(`\n${files.length} migration files`);
|
|
992
|
+
});
|
|
993
|
+
migrate
|
|
994
|
+
.command('create')
|
|
995
|
+
.description('Create a new empty migration file')
|
|
996
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
997
|
+
.requiredOption('--name <name>', 'Migration name')
|
|
998
|
+
.action(async (opts) => {
|
|
999
|
+
const path = await createMigration(opts.target, opts.name);
|
|
1000
|
+
console.log(`Created: ${path}`);
|
|
1001
|
+
});
|
|
1002
|
+
// ── Budget scenarios ────────────────────────────────────────────────
|
|
1003
|
+
const scenario = program.command('scenario').description('Budget scenario management')
|
|
1004
|
+
.addHelpText('after', `
|
|
1005
|
+
Examples:
|
|
1006
|
+
$ optimal scenario list
|
|
1007
|
+
$ optimal scenario save --name "4pct-growth" --adjustment-type percentage --adjustment-value 4
|
|
1008
|
+
$ optimal scenario compare --names "baseline,4pct-growth"
|
|
1009
|
+
$ optimal scenario delete --name "old-scenario"
|
|
1010
|
+
`);
|
|
1011
|
+
scenario
|
|
1012
|
+
.command('save')
|
|
1013
|
+
.description('Save current projections as a named scenario')
|
|
1014
|
+
.requiredOption('--name <name>', 'Scenario name')
|
|
1015
|
+
.requiredOption('--adjustment-type <type>', 'Adjustment type: percentage or flat')
|
|
1016
|
+
.requiredOption('--adjustment-value <n>', 'Adjustment value')
|
|
1017
|
+
.option('--description <desc>', 'Description')
|
|
1018
|
+
.option('--fiscal-year <fy>', 'Fiscal year', '2025')
|
|
1019
|
+
.option('--user-id <uuid>', 'User UUID')
|
|
1020
|
+
.action(async (opts) => {
|
|
1021
|
+
try {
|
|
1022
|
+
const path = await saveScenario({
|
|
1023
|
+
name: opts.name,
|
|
1024
|
+
adjustmentType: opts.adjustmentType,
|
|
1025
|
+
adjustmentValue: parseFloat(opts.adjustmentValue),
|
|
1026
|
+
fiscalYear: parseInt(opts.fiscalYear),
|
|
1027
|
+
userId: opts.userId,
|
|
1028
|
+
description: opts.description,
|
|
1029
|
+
});
|
|
1030
|
+
console.log(`Scenario saved: ${path}`);
|
|
1031
|
+
}
|
|
1032
|
+
catch (err) {
|
|
1033
|
+
console.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1034
|
+
process.exit(1);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
scenario
|
|
1038
|
+
.command('list')
|
|
1039
|
+
.description('List all saved budget scenarios')
|
|
1040
|
+
.action(async () => {
|
|
1041
|
+
const scenarios = await listScenarios();
|
|
1042
|
+
if (scenarios.length === 0) {
|
|
1043
|
+
console.log('No scenarios saved');
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
console.log('| Name | Adjustment | Projected | Change | Created |');
|
|
1047
|
+
console.log('|------|------------|-----------|--------|---------|');
|
|
1048
|
+
for (const s of scenarios) {
|
|
1049
|
+
const adj = s.adjustmentType === 'percentage' ? `${s.adjustmentValue}%` : `+${s.adjustmentValue}`;
|
|
1050
|
+
console.log(`| ${s.name} | ${adj} | ${s.totalProjected.toLocaleString()} | ${s.percentageChange.toFixed(1)}% | ${s.createdAt.slice(0, 10)} |`);
|
|
1051
|
+
}
|
|
1052
|
+
});
|
|
1053
|
+
scenario
|
|
1054
|
+
.command('compare')
|
|
1055
|
+
.description('Compare two or more scenarios side by side')
|
|
1056
|
+
.requiredOption('--names <csv>', 'Comma-separated scenario names')
|
|
1057
|
+
.action(async (opts) => {
|
|
1058
|
+
const names = opts.names.split(',').map(n => n.trim());
|
|
1059
|
+
if (names.length < 2) {
|
|
1060
|
+
console.error('Need at least 2 scenario names to compare');
|
|
1061
|
+
process.exit(1);
|
|
1062
|
+
}
|
|
1063
|
+
try {
|
|
1064
|
+
const result = await compareScenarios(names);
|
|
1065
|
+
// Print header
|
|
1066
|
+
const header = ['Program', 'Actual', ...result.scenarioNames].join(' | ');
|
|
1067
|
+
console.log(`| ${header} |`);
|
|
1068
|
+
console.log(`|${result.scenarioNames.map(() => '---').concat(['---', '---']).join('|')}|`);
|
|
1069
|
+
for (const p of result.programs.slice(0, 50)) {
|
|
1070
|
+
const vals = result.scenarioNames.map(n => String(p.projectedByScenario[n] ?? 0));
|
|
1071
|
+
console.log(`| ${p.programCode} | ${p.actual} | ${vals.join(' | ')} |`);
|
|
1072
|
+
}
|
|
1073
|
+
// Totals
|
|
1074
|
+
console.log('\nTotals:');
|
|
1075
|
+
for (const name of result.scenarioNames) {
|
|
1076
|
+
const t = result.totalsByScenario[name];
|
|
1077
|
+
console.log(` ${name}: ${t.totalProjected.toLocaleString()} (${t.percentageChange >= 0 ? '+' : ''}${t.percentageChange.toFixed(1)}%)`);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
console.error(`Compare failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1082
|
+
process.exit(1);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
scenario
|
|
1086
|
+
.command('delete')
|
|
1087
|
+
.description('Delete a saved scenario')
|
|
1088
|
+
.requiredOption('--name <name>', 'Scenario name')
|
|
1089
|
+
.action(async (opts) => {
|
|
1090
|
+
try {
|
|
1091
|
+
await deleteScenario(opts.name);
|
|
1092
|
+
console.log(`Deleted scenario: ${opts.name}`);
|
|
1093
|
+
}
|
|
1094
|
+
catch (err) {
|
|
1095
|
+
console.error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1096
|
+
process.exit(1);
|
|
1097
|
+
}
|
|
1098
|
+
});
|
|
1099
|
+
// ── Delete batch ────────────────────────────────────────────────────
|
|
1100
|
+
program
|
|
1101
|
+
.command('delete-batch')
|
|
1102
|
+
.description('Batch delete transactions or staging rows (safe: dry-run by default)')
|
|
1103
|
+
.requiredOption('--table <t>', 'Table: transactions or stg_financials_raw')
|
|
1104
|
+
.option('--user-id <uuid>', 'User UUID filter')
|
|
1105
|
+
.option('--date-from <date>', 'Start date YYYY-MM-DD')
|
|
1106
|
+
.option('--date-to <date>', 'End date YYYY-MM-DD')
|
|
1107
|
+
.option('--source <src>', 'Source filter')
|
|
1108
|
+
.option('--category <cat>', 'Category filter (transactions)')
|
|
1109
|
+
.option('--account-code <code>', 'Account code filter (staging)')
|
|
1110
|
+
.option('--month <YYYY-MM>', 'Month filter (staging)')
|
|
1111
|
+
.option('--execute', 'Actually delete (default is dry-run preview)', false)
|
|
1112
|
+
.action(async (opts) => {
|
|
1113
|
+
const table = opts.table;
|
|
1114
|
+
const filters = {
|
|
1115
|
+
dateFrom: opts.dateFrom,
|
|
1116
|
+
dateTo: opts.dateTo,
|
|
1117
|
+
source: opts.source,
|
|
1118
|
+
category: opts.category,
|
|
1119
|
+
accountCode: opts.accountCode,
|
|
1120
|
+
month: opts.month,
|
|
1121
|
+
};
|
|
1122
|
+
const dryRun = !opts.execute;
|
|
1123
|
+
if (dryRun) {
|
|
1124
|
+
const preview = await previewBatch({ table, userId: opts.userId, filters });
|
|
1125
|
+
console.log(`Preview: ${preview.matchCount} rows would be deleted from ${table}`);
|
|
1126
|
+
if (Object.keys(preview.groupedCounts).length > 0) {
|
|
1127
|
+
console.log('\nGrouped:');
|
|
1128
|
+
for (const [key, count] of Object.entries(preview.groupedCounts)) {
|
|
1129
|
+
console.log(` ${key}: ${count}`);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
if (preview.sample.length > 0) {
|
|
1133
|
+
console.log(`\nSample (first ${preview.sample.length}):`);
|
|
1134
|
+
for (const row of preview.sample) {
|
|
1135
|
+
console.log(` ${JSON.stringify(row)}`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
console.log('\nUse --execute to actually delete');
|
|
1139
|
+
}
|
|
1140
|
+
else {
|
|
1141
|
+
const result = await deleteBatch({ table, userId: opts.userId, filters, dryRun: false });
|
|
1142
|
+
console.log(`Deleted ${result.deletedCount} rows from ${table}`);
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
// ── Config registry (v1 scaffold) ─────────────────────────────────
|
|
1146
|
+
const config = program.command('config').description('Manage optimal-cli local/shared config profile')
|
|
1147
|
+
.addHelpText('after', `
|
|
1148
|
+
Examples:
|
|
1149
|
+
$ optimal config init --owner oracle --brand CRE-11TRUST
|
|
1150
|
+
$ optimal config doctor
|
|
1151
|
+
$ optimal config export --out ./backup.json
|
|
1152
|
+
$ optimal config import --in ./backup.json
|
|
1153
|
+
$ optimal config sync pull
|
|
1154
|
+
$ optimal config sync push --agent bot1
|
|
1155
|
+
`);
|
|
1156
|
+
config
|
|
1157
|
+
.command('init')
|
|
1158
|
+
.description('Create a local config scaffold (overwrites with --force)')
|
|
1159
|
+
.option('--owner <owner>', 'Config owner (default: $OPTIMAL_CONFIG_OWNER or $USER)')
|
|
1160
|
+
.option('--profile <name>', 'Profile name', 'default')
|
|
1161
|
+
.option('--brand <brand>', 'Default brand', 'CRE-11TRUST')
|
|
1162
|
+
.option('--timezone <tz>', 'Default timezone', 'America/New_York')
|
|
1163
|
+
.option('--force', 'Overwrite existing config', false)
|
|
1164
|
+
.action(async (opts) => {
|
|
1165
|
+
try {
|
|
1166
|
+
const existing = await readLocalConfig();
|
|
1167
|
+
if (existing && !opts.force) {
|
|
1168
|
+
console.error(`Config already exists at ${getLocalConfigPath()} (use --force to overwrite)`);
|
|
1169
|
+
process.exit(1);
|
|
1170
|
+
}
|
|
1171
|
+
const owner = opts.owner || process.env.OPTIMAL_CONFIG_OWNER || process.env.USER || 'oracle';
|
|
1172
|
+
const payload = {
|
|
1173
|
+
version: '1.0.0',
|
|
1174
|
+
profile: {
|
|
1175
|
+
name: opts.profile,
|
|
1176
|
+
owner,
|
|
1177
|
+
updated_at: new Date().toISOString(),
|
|
1178
|
+
},
|
|
1179
|
+
providers: {
|
|
1180
|
+
supabase: {
|
|
1181
|
+
project_ref: process.env.OPTIMAL_SUPABASE_PROJECT_REF || 'unset',
|
|
1182
|
+
url: process.env.OPTIMAL_SUPABASE_URL || 'unset',
|
|
1183
|
+
anon_key_present: Boolean(process.env.OPTIMAL_SUPABASE_ANON_KEY),
|
|
1184
|
+
},
|
|
1185
|
+
strapi: {
|
|
1186
|
+
base_url: process.env.STRAPI_BASE_URL || 'unset',
|
|
1187
|
+
token_present: Boolean(process.env.STRAPI_TOKEN),
|
|
1188
|
+
},
|
|
1189
|
+
},
|
|
1190
|
+
defaults: {
|
|
1191
|
+
brand: opts.brand,
|
|
1192
|
+
timezone: opts.timezone,
|
|
1193
|
+
},
|
|
1194
|
+
features: {
|
|
1195
|
+
cms: true,
|
|
1196
|
+
tasks: true,
|
|
1197
|
+
deploy: true,
|
|
1198
|
+
},
|
|
1199
|
+
};
|
|
1200
|
+
await writeLocalConfig(payload);
|
|
1201
|
+
await appendHistory(`${new Date().toISOString()} init profile=${opts.profile} owner=${owner} hash=${hashConfig(payload)}`);
|
|
1202
|
+
console.log(`Initialized config at ${getLocalConfigPath()}`);
|
|
1203
|
+
}
|
|
1204
|
+
catch (err) {
|
|
1205
|
+
console.error(`Config init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1206
|
+
process.exit(1);
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
1209
|
+
config
|
|
1210
|
+
.command('doctor')
|
|
1211
|
+
.description('Validate local config file and print health details')
|
|
1212
|
+
.action(async () => {
|
|
1213
|
+
try {
|
|
1214
|
+
const cfg = await readLocalConfig();
|
|
1215
|
+
if (!cfg) {
|
|
1216
|
+
console.log(`No local config found at ${getLocalConfigPath()}`);
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1219
|
+
const digest = hashConfig(cfg);
|
|
1220
|
+
console.log(`config: ok`);
|
|
1221
|
+
console.log(`path: ${getLocalConfigPath()}`);
|
|
1222
|
+
console.log(`profile: ${cfg.profile.name}`);
|
|
1223
|
+
console.log(`owner: ${cfg.profile.owner}`);
|
|
1224
|
+
console.log(`version: ${cfg.version}`);
|
|
1225
|
+
console.log(`hash: ${digest}`);
|
|
1226
|
+
console.log(`history: ${getHistoryPath()}`);
|
|
1227
|
+
}
|
|
1228
|
+
catch (err) {
|
|
1229
|
+
console.error(`Config doctor failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1230
|
+
process.exit(1);
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
config
|
|
1234
|
+
.command('export')
|
|
1235
|
+
.description('Export local config to a JSON path')
|
|
1236
|
+
.requiredOption('--out <path>', 'Output path for JSON export')
|
|
1237
|
+
.action(async (opts) => {
|
|
1238
|
+
try {
|
|
1239
|
+
const cfg = await readLocalConfig();
|
|
1240
|
+
if (!cfg) {
|
|
1241
|
+
console.error(`No local config found at ${getLocalConfigPath()}`);
|
|
1242
|
+
process.exit(1);
|
|
1243
|
+
}
|
|
1244
|
+
const payload = {
|
|
1245
|
+
...cfg,
|
|
1246
|
+
profile: {
|
|
1247
|
+
...cfg.profile,
|
|
1248
|
+
updated_at: new Date().toISOString(),
|
|
1249
|
+
},
|
|
1250
|
+
};
|
|
1251
|
+
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
|
1252
|
+
writeFileSync(opts.out, json, 'utf-8');
|
|
1253
|
+
await appendHistory(`${new Date().toISOString()} export out=${opts.out} hash=${hashConfig(payload)}`);
|
|
1254
|
+
console.log(`Exported config to ${opts.out}`);
|
|
1255
|
+
}
|
|
1256
|
+
catch (err) {
|
|
1257
|
+
console.error(`Config export failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1258
|
+
process.exit(1);
|
|
1259
|
+
}
|
|
1260
|
+
});
|
|
1261
|
+
config
|
|
1262
|
+
.command('import')
|
|
1263
|
+
.description('Import local config from a JSON path')
|
|
1264
|
+
.requiredOption('--in <path>', 'Input path for JSON config')
|
|
1265
|
+
.action(async (opts) => {
|
|
1266
|
+
try {
|
|
1267
|
+
if (!existsSync(opts.in)) {
|
|
1268
|
+
console.error(`Input file not found: ${opts.in}`);
|
|
1269
|
+
process.exit(1);
|
|
1270
|
+
}
|
|
1271
|
+
const raw = readFileSync(opts.in, 'utf-8');
|
|
1272
|
+
const parsed = JSON.parse(raw);
|
|
1273
|
+
const payload = assertOptimalConfigV1(parsed);
|
|
1274
|
+
await writeLocalConfig(payload);
|
|
1275
|
+
await appendHistory(`${new Date().toISOString()} import in=${opts.in} hash=${hashConfig(payload)}`);
|
|
1276
|
+
console.log(`Imported config from ${opts.in}`);
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
console.error(`Config import failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1280
|
+
process.exit(1);
|
|
1281
|
+
}
|
|
1282
|
+
});
|
|
1283
|
+
const configSync = config.command('sync').description('Sync local profile with shared registry (scaffold)');
|
|
1284
|
+
configSync
|
|
1285
|
+
.command('pull')
|
|
1286
|
+
.description('Pull config profile from shared registry into local config')
|
|
1287
|
+
.option('--profile <name>', 'Registry profile name', 'default')
|
|
1288
|
+
.action(async (opts) => {
|
|
1289
|
+
const result = await pullRegistryProfile(opts.profile);
|
|
1290
|
+
const stamp = new Date().toISOString();
|
|
1291
|
+
await appendHistory(`${stamp} sync.pull profile=${opts.profile} ok=${result.ok} msg=${result.message}`);
|
|
1292
|
+
if (!result.ok) {
|
|
1293
|
+
console.error(result.message);
|
|
1294
|
+
process.exit(1);
|
|
1295
|
+
}
|
|
1296
|
+
console.log(result.message);
|
|
1297
|
+
});
|
|
1298
|
+
configSync
|
|
1299
|
+
.command('push')
|
|
1300
|
+
.description('Push local config profile to shared registry')
|
|
1301
|
+
.requiredOption('--agent <name>', 'Agent/owner name for the config profile')
|
|
1302
|
+
.option('--profile <name>', 'Registry profile name', 'default')
|
|
1303
|
+
.option('--force', 'Force write even on conflict', false)
|
|
1304
|
+
.action(async (opts) => {
|
|
1305
|
+
const result = await pushRegistryProfile(opts.profile, Boolean(opts.force), opts.agent);
|
|
1306
|
+
const stamp = new Date().toISOString();
|
|
1307
|
+
await appendHistory(`${stamp} sync.push agent=${opts.agent} profile=${opts.profile} force=${Boolean(opts.force)} ok=${result.ok} msg=${result.message}`);
|
|
1308
|
+
if (!result.ok) {
|
|
1309
|
+
console.error(result.message);
|
|
1310
|
+
process.exit(1);
|
|
1311
|
+
}
|
|
1312
|
+
console.log(result.message);
|
|
1313
|
+
});
|
|
1314
|
+
// --- Bot commands ---
|
|
1315
|
+
const bot = program.command('bot').description('Bot agent orchestration')
|
|
1316
|
+
.addHelpText('after', `
|
|
1317
|
+
Examples:
|
|
1318
|
+
$ optimal bot agents List active agents
|
|
1319
|
+
$ optimal bot heartbeat --agent bot1 Send heartbeat
|
|
1320
|
+
$ optimal bot claim --agent bot1 Claim next task
|
|
1321
|
+
$ optimal bot report --task <id> --agent bot1 --message "50% done"
|
|
1322
|
+
$ optimal bot complete --task <id> --agent bot1 --summary "All tests pass"
|
|
1323
|
+
`);
|
|
1324
|
+
bot
|
|
1325
|
+
.command('heartbeat')
|
|
1326
|
+
.description('Send agent heartbeat')
|
|
1327
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1328
|
+
.option('--status <s>', 'Status: idle, working, error', 'idle')
|
|
1329
|
+
.action(async (opts) => {
|
|
1330
|
+
await sendHeartbeat(opts.agent, opts.status);
|
|
1331
|
+
success(`Heartbeat sent: ${colorize(opts.agent, 'bold')} [${colorize(opts.status, 'cyan')}]`);
|
|
1332
|
+
});
|
|
1333
|
+
bot
|
|
1334
|
+
.command('agents')
|
|
1335
|
+
.description('List active agents (heartbeat in last 5 min)')
|
|
1336
|
+
.action(async () => {
|
|
1337
|
+
const agents = await getActiveAgents();
|
|
1338
|
+
if (agents.length === 0) {
|
|
1339
|
+
console.log('No active agents.');
|
|
1340
|
+
return;
|
|
1341
|
+
}
|
|
1342
|
+
console.log('| Agent | Status | Last Seen |');
|
|
1343
|
+
console.log('|------------------|---------|---------------------|');
|
|
1344
|
+
for (const a of agents) {
|
|
1345
|
+
console.log(`| ${a.agent.padEnd(16)} | ${a.status.padEnd(7)} | ${a.lastSeen} |`);
|
|
1346
|
+
}
|
|
1347
|
+
});
|
|
1348
|
+
bot
|
|
1349
|
+
.command('claim')
|
|
1350
|
+
.description('Claim the next available task')
|
|
1351
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1352
|
+
.option('--skill <s>', 'Skill filter (comma-separated)')
|
|
1353
|
+
.action(async (opts) => {
|
|
1354
|
+
const skills = opts.skill ? opts.skill.split(',') : undefined;
|
|
1355
|
+
const task = await claimNextTask(opts.agent, skills);
|
|
1356
|
+
if (!task) {
|
|
1357
|
+
console.log('No claimable tasks found.');
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
success(`Claimed: ${colorize(task.title, 'cyan')} (${colorize(task.id, 'dim')}) by ${colorize(opts.agent, 'bold')}`);
|
|
1361
|
+
});
|
|
1362
|
+
bot
|
|
1363
|
+
.command('report')
|
|
1364
|
+
.description('Report progress on a task')
|
|
1365
|
+
.requiredOption('--task <id>', 'Task ID')
|
|
1366
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1367
|
+
.requiredOption('--message <msg>', 'Progress message')
|
|
1368
|
+
.action(async (opts) => {
|
|
1369
|
+
await reportProgress(opts.task, opts.agent, opts.message);
|
|
1370
|
+
success(`Progress reported on ${colorize(opts.task, 'dim')}`);
|
|
1371
|
+
});
|
|
1372
|
+
bot
|
|
1373
|
+
.command('complete')
|
|
1374
|
+
.description('Mark a task as done')
|
|
1375
|
+
.requiredOption('--task <id>', 'Task ID')
|
|
1376
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1377
|
+
.requiredOption('--summary <s>', 'Completion summary')
|
|
1378
|
+
.action(async (opts) => {
|
|
1379
|
+
await reportCompletion(opts.task, opts.agent, opts.summary);
|
|
1380
|
+
success(`Task ${colorize(opts.task, 'dim')} marked ${statusBadge('done')} by ${colorize(opts.agent, 'bold')}`);
|
|
1381
|
+
});
|
|
1382
|
+
bot
|
|
1383
|
+
.command('release')
|
|
1384
|
+
.description('Release a claimed task back to ready')
|
|
1385
|
+
.requiredOption('--task <id>', 'Task ID')
|
|
1386
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1387
|
+
.option('--reason <r>', 'Release reason')
|
|
1388
|
+
.action(async (opts) => {
|
|
1389
|
+
await releaseTask(opts.task, opts.agent, opts.reason);
|
|
1390
|
+
fmtInfo(`Task ${colorize(opts.task, 'dim')} released by ${colorize(opts.agent, 'bold')}`);
|
|
1391
|
+
});
|
|
1392
|
+
bot
|
|
1393
|
+
.command('blocked')
|
|
1394
|
+
.description('Mark a task as blocked')
|
|
1395
|
+
.requiredOption('--task <id>', 'Task ID')
|
|
1396
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1397
|
+
.requiredOption('--reason <r>', 'Block reason')
|
|
1398
|
+
.action(async (opts) => {
|
|
1399
|
+
await reportBlocked(opts.task, opts.agent, opts.reason);
|
|
1400
|
+
fmtWarn(`Task ${colorize(opts.task, 'dim')} marked ${statusBadge('blocked')}: ${opts.reason}`);
|
|
1401
|
+
});
|
|
1402
|
+
// --- Coordinator commands ---
|
|
1403
|
+
const coordinator = program.command('coordinator').description('Multi-agent coordination')
|
|
1404
|
+
.addHelpText('after', `
|
|
1405
|
+
Examples:
|
|
1406
|
+
$ optimal coordinator start Run coordinator loop
|
|
1407
|
+
$ optimal coordinator start --interval 10000 Poll every 10s
|
|
1408
|
+
$ optimal coordinator status Show coordinator status
|
|
1409
|
+
$ optimal coordinator assign --task <id> --agent bot1
|
|
1410
|
+
$ optimal coordinator rebalance Release stale tasks
|
|
1411
|
+
`);
|
|
1412
|
+
coordinator
|
|
1413
|
+
.command('start')
|
|
1414
|
+
.description('Run the coordinator loop')
|
|
1415
|
+
.option('--interval <ms>', 'Poll interval in milliseconds', '30000')
|
|
1416
|
+
.option('--max-agents <n>', 'Maximum agents to manage', '10')
|
|
1417
|
+
.action(async (opts) => {
|
|
1418
|
+
await runCoordinatorLoop({
|
|
1419
|
+
pollIntervalMs: parseInt(opts.interval),
|
|
1420
|
+
maxAgents: parseInt(opts.maxAgents),
|
|
1421
|
+
});
|
|
1422
|
+
});
|
|
1423
|
+
coordinator
|
|
1424
|
+
.command('status')
|
|
1425
|
+
.description('Show coordinator status')
|
|
1426
|
+
.action(async () => {
|
|
1427
|
+
const s = await getCoordinatorStatus();
|
|
1428
|
+
console.log(`Last poll: ${s.lastPollAt ?? 'never'}`);
|
|
1429
|
+
console.log(`Tasks — ready: ${s.tasksReady}, in progress: ${s.tasksInProgress}, blocked: ${s.tasksBlocked}`);
|
|
1430
|
+
console.log(`\nActive agents (${s.activeAgents.length}):`);
|
|
1431
|
+
for (const a of s.activeAgents) {
|
|
1432
|
+
console.log(` ${a.agent.padEnd(16)} ${a.status.padEnd(8)} last seen ${a.lastSeen}`);
|
|
1433
|
+
}
|
|
1434
|
+
console.log(`\nIdle agents (${s.idleAgents.length}):`);
|
|
1435
|
+
for (const a of s.idleAgents) {
|
|
1436
|
+
console.log(` ${a.id.padEnd(16)} skills: ${a.skills.join(', ')}`);
|
|
1437
|
+
}
|
|
1438
|
+
});
|
|
1439
|
+
coordinator
|
|
1440
|
+
.command('assign')
|
|
1441
|
+
.description('Manually assign a task to an agent')
|
|
1442
|
+
.requiredOption('--task <id>', 'Task ID')
|
|
1443
|
+
.requiredOption('--agent <id>', 'Agent ID')
|
|
1444
|
+
.action(async (opts) => {
|
|
1445
|
+
const task = await assignTask(opts.task, opts.agent);
|
|
1446
|
+
success(`Assigned: ${colorize(task.title, 'cyan')} -> ${colorize(opts.agent, 'bold')}`);
|
|
1447
|
+
});
|
|
1448
|
+
coordinator
|
|
1449
|
+
.command('rebalance')
|
|
1450
|
+
.description('Release stale tasks and rebalance')
|
|
1451
|
+
.action(async () => {
|
|
1452
|
+
const result = await rebalance();
|
|
1453
|
+
if (result.releasedTasks.length === 0) {
|
|
1454
|
+
fmtInfo('No stale tasks found.');
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
console.log(`Released ${result.releasedTasks.length} stale task(s):`);
|
|
1458
|
+
for (const t of result.releasedTasks) {
|
|
1459
|
+
console.log(` ${colorize(t.id, 'dim')} ${t.title}`);
|
|
1460
|
+
}
|
|
1461
|
+
if (result.reassignedTasks.length > 0) {
|
|
1462
|
+
console.log(`Reassigned ${result.reassignedTasks.length} task(s):`);
|
|
1463
|
+
for (const t of result.reassignedTasks) {
|
|
1464
|
+
console.log(` ${colorize(t.id, 'dim')} ${t.title} -> ${t.claimed_by}`);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
});
|
|
1468
|
+
// --- Asset commands ---
|
|
1469
|
+
const asset = program.command('asset').description('Digital asset tracking (domains, servers, API keys, services, repos)')
|
|
1470
|
+
.addHelpText('after', `
|
|
1471
|
+
Examples:
|
|
1472
|
+
$ optimal asset list List all assets
|
|
1473
|
+
$ optimal asset list --type domain --status active Filter by type/status
|
|
1474
|
+
$ optimal asset add --name "op-hub.com" --type domain --owner clenisa
|
|
1475
|
+
$ optimal asset update --id <uuid> --status inactive
|
|
1476
|
+
$ optimal asset usage --id <uuid> View usage log
|
|
1477
|
+
`);
|
|
1478
|
+
asset
|
|
1479
|
+
.command('list')
|
|
1480
|
+
.description('List tracked assets')
|
|
1481
|
+
.option('-t, --type <type>', 'Filter by type (domain, server, api_key, service, repo, other)')
|
|
1482
|
+
.option('-s, --status <status>', 'Filter by status (active, inactive, expired, pending)')
|
|
1483
|
+
.option('-o, --owner <owner>', 'Filter by owner')
|
|
1484
|
+
.option('--json', 'Output as JSON')
|
|
1485
|
+
.action(async (opts) => {
|
|
1486
|
+
const assets = await listAssets({
|
|
1487
|
+
type: opts.type,
|
|
1488
|
+
status: opts.status,
|
|
1489
|
+
owner: opts.owner,
|
|
1490
|
+
});
|
|
1491
|
+
if (opts.json) {
|
|
1492
|
+
console.log(JSON.stringify(assets, null, 2));
|
|
1493
|
+
}
|
|
1494
|
+
else {
|
|
1495
|
+
console.log(formatAssetTable(assets));
|
|
1496
|
+
}
|
|
1497
|
+
});
|
|
1498
|
+
asset
|
|
1499
|
+
.command('add')
|
|
1500
|
+
.description('Add a new asset')
|
|
1501
|
+
.requiredOption('-n, --name <name>', 'Asset name')
|
|
1502
|
+
.requiredOption('-t, --type <type>', 'Asset type (domain, server, api_key, service, repo, other)')
|
|
1503
|
+
.option('-s, --status <status>', 'Status (default: active)')
|
|
1504
|
+
.option('-o, --owner <owner>', 'Owner')
|
|
1505
|
+
.option('--expires <date>', 'Expiration date (YYYY-MM-DD or ISO)')
|
|
1506
|
+
.option('--meta <json>', 'Metadata JSON string')
|
|
1507
|
+
.action(async (opts) => {
|
|
1508
|
+
const metadata = opts.meta ? JSON.parse(opts.meta) : undefined;
|
|
1509
|
+
const created = await createAsset({
|
|
1510
|
+
name: opts.name,
|
|
1511
|
+
type: opts.type,
|
|
1512
|
+
status: opts.status,
|
|
1513
|
+
owner: opts.owner,
|
|
1514
|
+
expires_at: opts.expires,
|
|
1515
|
+
metadata,
|
|
1516
|
+
});
|
|
1517
|
+
success(`Created asset: ${colorize(created.name, 'cyan')} [${created.type}] (${colorize(created.id, 'dim')})`);
|
|
1518
|
+
});
|
|
1519
|
+
asset
|
|
1520
|
+
.command('update')
|
|
1521
|
+
.description('Update an existing asset')
|
|
1522
|
+
.requiredOption('--id <uuid>', 'Asset ID')
|
|
1523
|
+
.option('-n, --name <name>', 'New name')
|
|
1524
|
+
.option('-t, --type <type>', 'New type')
|
|
1525
|
+
.option('-s, --status <status>', 'New status')
|
|
1526
|
+
.option('-o, --owner <owner>', 'New owner')
|
|
1527
|
+
.option('--expires <date>', 'New expiration date')
|
|
1528
|
+
.option('--meta <json>', 'New metadata JSON')
|
|
1529
|
+
.action(async (opts) => {
|
|
1530
|
+
const updates = {};
|
|
1531
|
+
if (opts.name)
|
|
1532
|
+
updates.name = opts.name;
|
|
1533
|
+
if (opts.type)
|
|
1534
|
+
updates.type = opts.type;
|
|
1535
|
+
if (opts.status)
|
|
1536
|
+
updates.status = opts.status;
|
|
1537
|
+
if (opts.owner)
|
|
1538
|
+
updates.owner = opts.owner;
|
|
1539
|
+
if (opts.expires)
|
|
1540
|
+
updates.expires_at = opts.expires;
|
|
1541
|
+
if (opts.meta)
|
|
1542
|
+
updates.metadata = JSON.parse(opts.meta);
|
|
1543
|
+
const updated = await updateAsset(opts.id, updates);
|
|
1544
|
+
success(`Updated: ${colorize(updated.name, 'cyan')} -> status=${colorize(updated.status, 'bold')}`);
|
|
1545
|
+
});
|
|
1546
|
+
asset
|
|
1547
|
+
.command('get')
|
|
1548
|
+
.description('Get a single asset by ID')
|
|
1549
|
+
.requiredOption('--id <uuid>', 'Asset ID')
|
|
1550
|
+
.action(async (opts) => {
|
|
1551
|
+
const a = await getAsset(opts.id);
|
|
1552
|
+
console.log(JSON.stringify(a, null, 2));
|
|
1553
|
+
});
|
|
1554
|
+
asset
|
|
1555
|
+
.command('remove')
|
|
1556
|
+
.description('Delete an asset')
|
|
1557
|
+
.requiredOption('--id <uuid>', 'Asset ID')
|
|
1558
|
+
.action(async (opts) => {
|
|
1559
|
+
await deleteAsset(opts.id);
|
|
1560
|
+
success(`Deleted asset ${colorize(opts.id, 'dim')}`);
|
|
1561
|
+
});
|
|
1562
|
+
asset
|
|
1563
|
+
.command('track')
|
|
1564
|
+
.description('Log a usage event for an asset')
|
|
1565
|
+
.requiredOption('--id <uuid>', 'Asset ID')
|
|
1566
|
+
.requiredOption('-e, --event <event>', 'Event name (e.g. "renewed", "deployed", "rotated")')
|
|
1567
|
+
.option('--actor <name>', 'Who performed the action')
|
|
1568
|
+
.option('--meta <json>', 'Event metadata JSON')
|
|
1569
|
+
.action(async (opts) => {
|
|
1570
|
+
const metadata = opts.meta ? JSON.parse(opts.meta) : undefined;
|
|
1571
|
+
const entry = await trackAssetUsage(opts.id, opts.event, opts.actor, metadata);
|
|
1572
|
+
success(`Tracked: ${colorize(opts.event, 'cyan')} on ${colorize(opts.id, 'dim')} at ${colorize(entry.created_at, 'dim')}`);
|
|
1573
|
+
});
|
|
1574
|
+
asset
|
|
1575
|
+
.command('usage')
|
|
1576
|
+
.description('View usage log for an asset')
|
|
1577
|
+
.requiredOption('--id <uuid>', 'Asset ID')
|
|
1578
|
+
.option('--limit <n>', 'Max entries', '20')
|
|
1579
|
+
.action(async (opts) => {
|
|
1580
|
+
const events = await listAssetUsage(opts.id, parseInt(opts.limit));
|
|
1581
|
+
if (events.length === 0) {
|
|
1582
|
+
console.log('No usage events found.');
|
|
1583
|
+
return;
|
|
1584
|
+
}
|
|
1585
|
+
for (const e of events) {
|
|
1586
|
+
console.log(`${e.created_at} | ${(e.actor ?? '-').padEnd(10)} | ${e.event} ${Object.keys(e.metadata).length > 0 ? JSON.stringify(e.metadata) : ''}`);
|
|
1587
|
+
}
|
|
1588
|
+
console.log(`\n${events.length} events`);
|
|
1589
|
+
});
|
|
1590
|
+
program.parseAsync();
|