optimal-cli 0.1.0
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/README.md +175 -0
- package/dist/bin/optimal.d.ts +2 -0
- package/dist/bin/optimal.js +995 -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.d.ts +55 -0
- package/dist/lib/config.js +206 -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/kanban.d.ts +46 -0
- package/dist/lib/kanban.js +118 -0
- package/dist/lib/newsletter/distribute.d.ts +52 -0
- package/dist/lib/newsletter/distribute.js +193 -0
- package/dist/lib/newsletter/generate-insurance.d.ts +42 -0
- 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/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 +50 -0
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
import { getBoard, createTask, updateTask, logActivity, } from '../lib/kanban.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 } 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 { migrateDb, listPendingMigrations, createMigration } from '../lib/infra/migrate.js';
|
|
25
|
+
import { saveScenario, listScenarios, compareScenarios, deleteScenario } from '../lib/budget/scenarios.js';
|
|
26
|
+
import { deleteBatch, previewBatch } from '../lib/transactions/delete-batch.js';
|
|
27
|
+
import { pushConfig, pullConfig, listConfigs, diffConfig, syncConfig } from '../lib/config.js';
|
|
28
|
+
const program = new Command()
|
|
29
|
+
.name('optimal')
|
|
30
|
+
.description('Optimal CLI — unified skills for financial analytics, content, and infra')
|
|
31
|
+
.version('0.1.0');
|
|
32
|
+
// Board commands
|
|
33
|
+
const board = program.command('board').description('Kanban board operations');
|
|
34
|
+
board
|
|
35
|
+
.command('view')
|
|
36
|
+
.description('Display the kanban board')
|
|
37
|
+
.option('-p, --project <slug>', 'Project slug', 'optimal-cli-refactor')
|
|
38
|
+
.option('-s, --status <status>', 'Filter by status')
|
|
39
|
+
.action(async (opts) => {
|
|
40
|
+
let tasks = await getBoard(opts.project);
|
|
41
|
+
if (opts.status)
|
|
42
|
+
tasks = tasks.filter(t => t.status === opts.status);
|
|
43
|
+
const grouped = new Map();
|
|
44
|
+
for (const t of tasks) {
|
|
45
|
+
const list = grouped.get(t.status) ?? [];
|
|
46
|
+
list.push(t);
|
|
47
|
+
grouped.set(t.status, list);
|
|
48
|
+
}
|
|
49
|
+
const order = ['in_progress', 'blocked', 'ready', 'backlog', 'review', 'done'];
|
|
50
|
+
console.log('| Status | P | Title | Agent | Skill |');
|
|
51
|
+
console.log('|--------|---|-------|-------|-------|');
|
|
52
|
+
for (const status of order) {
|
|
53
|
+
const list = grouped.get(status) ?? [];
|
|
54
|
+
for (const t of list) {
|
|
55
|
+
console.log(`| ${t.status} | ${t.priority} | ${t.title} | ${t.assigned_agent ?? '—'} | ${t.skill_ref ?? '—'} |`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log(`\nTotal: ${tasks.length} tasks`);
|
|
59
|
+
});
|
|
60
|
+
board
|
|
61
|
+
.command('create')
|
|
62
|
+
.description('Create a new task')
|
|
63
|
+
.requiredOption('-t, --title <title>', 'Task title')
|
|
64
|
+
.option('-p, --project <slug>', 'Project slug', 'optimal-cli-refactor')
|
|
65
|
+
.option('-d, --description <desc>', 'Task description')
|
|
66
|
+
.option('--priority <n>', 'Priority 1-4', '3')
|
|
67
|
+
.option('--skill <ref>', 'Skill reference')
|
|
68
|
+
.option('--labels <labels>', 'Comma-separated labels')
|
|
69
|
+
.action(async (opts) => {
|
|
70
|
+
const task = await createTask({
|
|
71
|
+
project_slug: opts.project,
|
|
72
|
+
title: opts.title,
|
|
73
|
+
description: opts.description,
|
|
74
|
+
priority: parseInt(opts.priority),
|
|
75
|
+
skill_ref: opts.skill,
|
|
76
|
+
labels: opts.labels?.split(',').map((l) => l.trim()),
|
|
77
|
+
});
|
|
78
|
+
console.log(`Created task: ${task.id} — "${task.title}" (priority ${task.priority}, status ${task.status})`);
|
|
79
|
+
});
|
|
80
|
+
board
|
|
81
|
+
.command('update')
|
|
82
|
+
.description('Update a task')
|
|
83
|
+
.requiredOption('--id <taskId>', 'Task UUID')
|
|
84
|
+
.option('-s, --status <status>', 'New status')
|
|
85
|
+
.option('-a, --agent <name>', 'Assign to agent')
|
|
86
|
+
.option('--priority <n>', 'New priority')
|
|
87
|
+
.option('-m, --message <msg>', 'Log message')
|
|
88
|
+
.action(async (opts) => {
|
|
89
|
+
const updates = {};
|
|
90
|
+
if (opts.status)
|
|
91
|
+
updates.status = opts.status;
|
|
92
|
+
if (opts.agent)
|
|
93
|
+
updates.assigned_agent = opts.agent;
|
|
94
|
+
if (opts.priority)
|
|
95
|
+
updates.priority = parseInt(opts.priority);
|
|
96
|
+
const task = await updateTask(opts.id, updates);
|
|
97
|
+
if (opts.message) {
|
|
98
|
+
await logActivity(opts.id, {
|
|
99
|
+
agent: opts.agent ?? 'cli',
|
|
100
|
+
action: 'status_change',
|
|
101
|
+
message: opts.message,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
console.log(`Updated task ${task.id}: status → ${task.status}, agent → ${task.assigned_agent ?? '—'}`);
|
|
105
|
+
});
|
|
106
|
+
// Audit financials command
|
|
107
|
+
program
|
|
108
|
+
.command('audit-financials')
|
|
109
|
+
.description('Compare staged financials against confirmed income statements')
|
|
110
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months to audit (default: all)')
|
|
111
|
+
.option('--tolerance <n>', 'Dollar tolerance for match detection', '1.00')
|
|
112
|
+
.action(async (opts) => {
|
|
113
|
+
const months = opts.months
|
|
114
|
+
? opts.months.split(',').map((m) => m.trim())
|
|
115
|
+
: undefined;
|
|
116
|
+
const tolerance = parseFloat(opts.tolerance);
|
|
117
|
+
console.log('Fetching financial data...');
|
|
118
|
+
const result = await runAuditComparison(months, tolerance);
|
|
119
|
+
console.log(`\nStaging rows: ${result.totalStagingRows} | Confirmed rows: ${result.totalConfirmedRows}`);
|
|
120
|
+
console.log(`Tolerance: $${tolerance.toFixed(2)}\n`);
|
|
121
|
+
// Header
|
|
122
|
+
console.log('| Month | Confirmed | Staged | Match | SignFlip | Mismatch | C-Only | S-Only | Accuracy |');
|
|
123
|
+
console.log('|---------|-----------|--------|-------|---------|----------|--------|--------|----------|');
|
|
124
|
+
let flagged = false;
|
|
125
|
+
for (const s of result.summaries) {
|
|
126
|
+
const acc = s.accuracy !== null ? `${s.accuracy}%` : 'N/A';
|
|
127
|
+
const warn = s.accuracy !== null && s.accuracy < 100 ? ' *' : '';
|
|
128
|
+
if (warn)
|
|
129
|
+
flagged = true;
|
|
130
|
+
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)} |`);
|
|
131
|
+
}
|
|
132
|
+
if (flagged) {
|
|
133
|
+
console.log('\n* Months below 100% accuracy — investigate mismatches');
|
|
134
|
+
}
|
|
135
|
+
// Totals row
|
|
136
|
+
if (result.summaries.length > 1) {
|
|
137
|
+
const totals = result.summaries.reduce((acc, s) => ({
|
|
138
|
+
confirmed: acc.confirmed + s.confirmedAccounts,
|
|
139
|
+
staged: acc.staged + s.stagedAccounts,
|
|
140
|
+
exact: acc.exact + s.exactMatch,
|
|
141
|
+
flip: acc.flip + s.signFlipMatch,
|
|
142
|
+
mismatch: acc.mismatch + s.mismatch,
|
|
143
|
+
cOnly: acc.cOnly + s.confirmedOnly,
|
|
144
|
+
sOnly: acc.sOnly + s.stagingOnly,
|
|
145
|
+
}), { confirmed: 0, staged: 0, exact: 0, flip: 0, mismatch: 0, cOnly: 0, sOnly: 0 });
|
|
146
|
+
const totalOverlap = totals.exact + totals.flip + totals.mismatch;
|
|
147
|
+
const totalAcc = totalOverlap > 0
|
|
148
|
+
? Math.round(((totals.exact + totals.flip) / totalOverlap) * 1000) / 10
|
|
149
|
+
: null;
|
|
150
|
+
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)} |`);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
// Export KPIs command
|
|
154
|
+
program
|
|
155
|
+
.command('export-kpis')
|
|
156
|
+
.description('Export KPI totals by program/client from ReturnPro financial data')
|
|
157
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months (default: 3 most recent)')
|
|
158
|
+
.option('--programs <csv>', 'Comma-separated program name substrings to filter')
|
|
159
|
+
.option('--format <fmt>', 'Output format: table or csv', 'table')
|
|
160
|
+
.action(async (opts) => {
|
|
161
|
+
const months = opts.months
|
|
162
|
+
? opts.months.split(',').map((m) => m.trim())
|
|
163
|
+
: undefined;
|
|
164
|
+
const programs = opts.programs
|
|
165
|
+
? opts.programs.split(',').map((p) => p.trim())
|
|
166
|
+
: undefined;
|
|
167
|
+
const format = opts.format;
|
|
168
|
+
if (format !== 'table' && format !== 'csv') {
|
|
169
|
+
console.error(`Invalid format "${format}". Use "table" or "csv".`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
console.error('Fetching KPI data...');
|
|
173
|
+
const rows = await exportKpis({ months, programs });
|
|
174
|
+
console.error(`Fetched ${rows.length} KPI rows`);
|
|
175
|
+
if (format === 'csv') {
|
|
176
|
+
console.log(formatKpiCsv(rows));
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
console.log(formatKpiTable(rows));
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
// Deploy command
|
|
183
|
+
program
|
|
184
|
+
.command('deploy')
|
|
185
|
+
.description('Deploy an app to Vercel (preview or production)')
|
|
186
|
+
.argument('<app>', `App to deploy (${listApps().join(', ')})`)
|
|
187
|
+
.option('--prod', 'Deploy to production', false)
|
|
188
|
+
.action(async (app, opts) => {
|
|
189
|
+
console.log(`Deploying ${app}${opts.prod ? ' (production)' : ' (preview)'}...`);
|
|
190
|
+
try {
|
|
191
|
+
const url = await deploy(app, opts.prod);
|
|
192
|
+
console.log(`Deployed: ${url}`);
|
|
193
|
+
}
|
|
194
|
+
catch (err) {
|
|
195
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
196
|
+
console.error(`Deploy failed: ${msg}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
// Health check command
|
|
201
|
+
program
|
|
202
|
+
.command('health-check')
|
|
203
|
+
.description('Run health check across all Optimal services')
|
|
204
|
+
.action(async () => {
|
|
205
|
+
try {
|
|
206
|
+
const output = await healthCheck();
|
|
207
|
+
console.log(output);
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
211
|
+
console.error(`Health check failed: ${msg}`);
|
|
212
|
+
process.exit(1);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
// Budget projection commands
|
|
216
|
+
async function loadProjectionData(opts) {
|
|
217
|
+
if (opts.file) {
|
|
218
|
+
const raw = readFileSync(opts.file, 'utf-8');
|
|
219
|
+
return parseSummaryFromJson(raw);
|
|
220
|
+
}
|
|
221
|
+
const fy = opts.fiscalYear ? parseInt(opts.fiscalYear) : 2025;
|
|
222
|
+
return fetchWesImports({ fiscalYear: fy, userId: opts.userId });
|
|
223
|
+
}
|
|
224
|
+
function resolveAdjustmentType(raw) {
|
|
225
|
+
if (raw === 'flat')
|
|
226
|
+
return 'flat';
|
|
227
|
+
return 'percentage';
|
|
228
|
+
}
|
|
229
|
+
program
|
|
230
|
+
.command('project-budget')
|
|
231
|
+
.description('Run FY26 budget projections with adjustments on FY25 checked-in units')
|
|
232
|
+
.option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
|
|
233
|
+
.option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
|
|
234
|
+
.option('--format <fmt>', 'Output format: table or csv', 'table')
|
|
235
|
+
.option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
|
|
236
|
+
.option('--user-id <uuid>', 'Supabase user UUID to filter by')
|
|
237
|
+
.option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
|
|
238
|
+
.action(async (opts) => {
|
|
239
|
+
const format = opts.format;
|
|
240
|
+
if (format !== 'table' && format !== 'csv') {
|
|
241
|
+
console.error(`Invalid format "${format}". Use "table" or "csv".`);
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
console.error('Loading projection data...');
|
|
245
|
+
const summary = await loadProjectionData(opts);
|
|
246
|
+
console.error(`Loaded ${summary.length} programs`);
|
|
247
|
+
let projections = initializeProjections(summary);
|
|
248
|
+
const adjType = resolveAdjustmentType(opts.adjustmentType);
|
|
249
|
+
const adjValue = parseFloat(opts.adjustmentValue);
|
|
250
|
+
if (adjValue !== 0) {
|
|
251
|
+
projections = applyUniformAdjustment(projections, adjType, adjValue);
|
|
252
|
+
console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
|
|
253
|
+
}
|
|
254
|
+
const totals = calculateTotals(projections);
|
|
255
|
+
console.error(`Totals: ${totals.totalActual} actual -> ${totals.totalProjected} projected (${totals.percentageChange >= 0 ? '+' : ''}${totals.percentageChange.toFixed(1)}%)`);
|
|
256
|
+
if (format === 'csv') {
|
|
257
|
+
console.log(exportToCSV(projections));
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
console.log(formatProjectionTable(projections));
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
program
|
|
264
|
+
.command('export-budget')
|
|
265
|
+
.description('Export FY26 budget projections as CSV')
|
|
266
|
+
.option('--adjustment-type <type>', 'Adjustment type: percent or flat', 'percent')
|
|
267
|
+
.option('--adjustment-value <n>', 'Adjustment value (e.g., 4 for 4%)', '0')
|
|
268
|
+
.option('--fiscal-year <fy>', 'Base fiscal year for actuals', '2025')
|
|
269
|
+
.option('--user-id <uuid>', 'Supabase user UUID to filter by')
|
|
270
|
+
.option('--file <path>', 'JSON file of CheckedInUnitsSummary[] (skips Supabase)')
|
|
271
|
+
.action(async (opts) => {
|
|
272
|
+
console.error('Loading projection data...');
|
|
273
|
+
const summary = await loadProjectionData(opts);
|
|
274
|
+
console.error(`Loaded ${summary.length} programs`);
|
|
275
|
+
let projections = initializeProjections(summary);
|
|
276
|
+
const adjType = resolveAdjustmentType(opts.adjustmentType);
|
|
277
|
+
const adjValue = parseFloat(opts.adjustmentValue);
|
|
278
|
+
if (adjValue !== 0) {
|
|
279
|
+
projections = applyUniformAdjustment(projections, adjType, adjValue);
|
|
280
|
+
console.error(`Applied ${adjType} adjustment: ${adjType === 'percentage' ? `${adjValue}%` : `${adjValue >= 0 ? '+' : ''}${adjValue} units`}`);
|
|
281
|
+
}
|
|
282
|
+
console.log(exportToCSV(projections));
|
|
283
|
+
});
|
|
284
|
+
// Newsletter generation command
|
|
285
|
+
program
|
|
286
|
+
.command('generate-newsletter')
|
|
287
|
+
.description('Generate a branded newsletter with AI content and push to Strapi CMS')
|
|
288
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
289
|
+
.option('--date <date>', 'Edition date as YYYY-MM-DD (default: today)')
|
|
290
|
+
.option('--excel <path>', 'Path to Excel file with property listings (CRE-11TRUST only)')
|
|
291
|
+
.option('--dry-run', 'Generate content but do NOT push to Strapi', false)
|
|
292
|
+
.action(async (opts) => {
|
|
293
|
+
try {
|
|
294
|
+
const result = await generateNewsletter({
|
|
295
|
+
brand: opts.brand,
|
|
296
|
+
date: opts.date,
|
|
297
|
+
excelPath: opts.excel,
|
|
298
|
+
dryRun: opts.dryRun,
|
|
299
|
+
});
|
|
300
|
+
if (result.strapiDocumentId) {
|
|
301
|
+
console.log(`\nStrapi documentId: ${result.strapiDocumentId}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
306
|
+
console.error(`Newsletter generation failed: ${msg}`);
|
|
307
|
+
process.exit(1);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
// Scrape Meta Ad Library command
|
|
311
|
+
program
|
|
312
|
+
.command('scrape-ads')
|
|
313
|
+
.description('Scrape Meta Ad Library for competitor ad intelligence')
|
|
314
|
+
.requiredOption('--companies <csv-or-file>', 'Comma-separated company names or path to a text file (one per line)')
|
|
315
|
+
.option('--output <path>', 'Save CSV results to file (default: stdout)')
|
|
316
|
+
.option('--batch-size <n>', 'Companies per batch', '6')
|
|
317
|
+
.action(async (opts) => {
|
|
318
|
+
// Parse companies: file path or comma-separated list
|
|
319
|
+
let companies;
|
|
320
|
+
if (existsSync(opts.companies)) {
|
|
321
|
+
const raw = readFileSync(opts.companies, 'utf-8');
|
|
322
|
+
companies = raw
|
|
323
|
+
.split('\n')
|
|
324
|
+
.map((l) => l.trim())
|
|
325
|
+
.filter((l) => l.length > 0 && !l.startsWith('#'));
|
|
326
|
+
}
|
|
327
|
+
else {
|
|
328
|
+
companies = opts.companies
|
|
329
|
+
.split(',')
|
|
330
|
+
.map((c) => c.trim())
|
|
331
|
+
.filter((c) => c.length > 0);
|
|
332
|
+
}
|
|
333
|
+
if (companies.length === 0) {
|
|
334
|
+
console.error('No companies specified');
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
const batchSize = parseInt(opts.batchSize);
|
|
338
|
+
if (isNaN(batchSize) || batchSize < 1) {
|
|
339
|
+
console.error('Invalid batch size');
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
try {
|
|
343
|
+
const result = await scrapeCompanies({
|
|
344
|
+
companies,
|
|
345
|
+
outputPath: opts.output,
|
|
346
|
+
batchSize,
|
|
347
|
+
});
|
|
348
|
+
// If no output file, write CSV to stdout
|
|
349
|
+
if (!opts.output) {
|
|
350
|
+
process.stdout.write(formatCsv(result.ads));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
catch (err) {
|
|
354
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
355
|
+
console.error(`Scrape failed: ${msg}`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
// Ingest transactions command
|
|
360
|
+
program
|
|
361
|
+
.command('ingest-transactions')
|
|
362
|
+
.description('Parse & deduplicate bank CSV files into the transactions table')
|
|
363
|
+
.requiredOption('--file <path>', 'Path to the CSV file')
|
|
364
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
365
|
+
.action(async (opts) => {
|
|
366
|
+
if (!existsSync(opts.file)) {
|
|
367
|
+
console.error(`File not found: ${opts.file}`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
}
|
|
370
|
+
console.log(`Ingesting transactions from: ${opts.file}`);
|
|
371
|
+
try {
|
|
372
|
+
const result = await ingestTransactions(opts.file, opts.userId);
|
|
373
|
+
console.log(`\nFormat detected: ${result.format}`);
|
|
374
|
+
console.log(`Inserted: ${result.inserted} | Skipped (duplicates): ${result.skipped} | Failed: ${result.failed}`);
|
|
375
|
+
if (result.errors.length > 0) {
|
|
376
|
+
console.log(`\nWarnings/Errors (${result.errors.length}):`);
|
|
377
|
+
for (const err of result.errors.slice(0, 20)) {
|
|
378
|
+
console.log(` - ${err}`);
|
|
379
|
+
}
|
|
380
|
+
if (result.errors.length > 20) {
|
|
381
|
+
console.log(` ... and ${result.errors.length - 20} more`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (err) {
|
|
386
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
387
|
+
console.error(`Ingest failed: ${msg}`);
|
|
388
|
+
process.exit(1);
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
// Stamp transactions command
|
|
392
|
+
program
|
|
393
|
+
.command('stamp-transactions')
|
|
394
|
+
.description('Auto-categorize unclassified transactions using rule-based matching')
|
|
395
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
396
|
+
.option('--dry-run', 'Preview matches without writing to database', false)
|
|
397
|
+
.action(async (opts) => {
|
|
398
|
+
console.log(`Stamping transactions for user: ${opts.userId}${opts.dryRun ? ' (DRY RUN)' : ''}`);
|
|
399
|
+
try {
|
|
400
|
+
const result = await stampTransactions(opts.userId, { dryRun: opts.dryRun });
|
|
401
|
+
console.log(`\nTotal unclassified: ${result.total}`);
|
|
402
|
+
console.log(`Stamped: ${result.stamped} | Unmatched: ${result.unmatched}`);
|
|
403
|
+
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}`);
|
|
404
|
+
if (result.dryRun) {
|
|
405
|
+
console.log('\n(Dry run — no database changes made)');
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
410
|
+
console.error(`Stamp failed: ${msg}`);
|
|
411
|
+
process.exit(1);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
// ── Upload R1 data ──────────────────────────────────────────────────
|
|
415
|
+
program
|
|
416
|
+
.command('upload-r1')
|
|
417
|
+
.description('Upload R1 XLSX file to ReturnPro staging')
|
|
418
|
+
.requiredOption('--file <path>', 'Path to R1 XLSX file')
|
|
419
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
420
|
+
.requiredOption('--month <YYYY-MM>', 'Month in YYYY-MM format')
|
|
421
|
+
.action(async (opts) => {
|
|
422
|
+
if (!existsSync(opts.file)) {
|
|
423
|
+
console.error(`File not found: ${opts.file}`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
try {
|
|
427
|
+
const result = await processR1Upload(opts.file, opts.userId, opts.month);
|
|
428
|
+
console.log(`R1 upload complete: ${result.rowsInserted} rows inserted, ${result.rowsSkipped} skipped (${result.programGroupsFound} program groups)`);
|
|
429
|
+
if (result.warnings.length > 0) {
|
|
430
|
+
console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
console.error(`R1 upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
// ── Upload NetSuite data ────────────────────────────────────────────
|
|
439
|
+
program
|
|
440
|
+
.command('upload-netsuite')
|
|
441
|
+
.description('Upload NetSuite CSV/XLSX to ReturnPro staging')
|
|
442
|
+
.requiredOption('--file <path>', 'Path to NetSuite file (CSV, XLSX, or XLSM)')
|
|
443
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
444
|
+
.action(async (opts) => {
|
|
445
|
+
if (!existsSync(opts.file)) {
|
|
446
|
+
console.error(`File not found: ${opts.file}`);
|
|
447
|
+
process.exit(1);
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const result = await processNetSuiteUpload(opts.file, opts.userId);
|
|
451
|
+
console.log(`NetSuite upload: ${result.inserted} rows inserted (months: ${result.monthsCovered.join(', ')})`);
|
|
452
|
+
if (result.warnings.length > 0) {
|
|
453
|
+
console.log(`Warnings: ${result.warnings.slice(0, 10).join(', ')}`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
catch (err) {
|
|
457
|
+
console.error(`NetSuite upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
458
|
+
process.exit(1);
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
// ── Upload income statements ────────────────────────────────────────
|
|
462
|
+
program
|
|
463
|
+
.command('upload-income-statements')
|
|
464
|
+
.description('Upload confirmed income statement CSV to ReturnPro')
|
|
465
|
+
.requiredOption('--file <path>', 'Path to income statement CSV')
|
|
466
|
+
.requiredOption('--user-id <uuid>', 'Supabase user UUID')
|
|
467
|
+
.action(async (opts) => {
|
|
468
|
+
if (!existsSync(opts.file)) {
|
|
469
|
+
console.error(`File not found: ${opts.file}`);
|
|
470
|
+
process.exit(1);
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
const result = await uploadIncomeStatements(opts.file, opts.userId);
|
|
474
|
+
console.log(`Income statements: ${result.upserted} rows upserted, ${result.skipped} skipped (period: ${result.period})`);
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
console.error(`Upload failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
// ── Rate anomalies ──────────────────────────────────────────────────
|
|
482
|
+
program
|
|
483
|
+
.command('rate-anomalies')
|
|
484
|
+
.description('Detect rate anomalies via z-score analysis on ReturnPro data')
|
|
485
|
+
.option('--from <YYYY-MM>', 'Start month')
|
|
486
|
+
.option('--to <YYYY-MM>', 'End month')
|
|
487
|
+
.option('--threshold <n>', 'Z-score threshold', '2.0')
|
|
488
|
+
.action(async (opts) => {
|
|
489
|
+
try {
|
|
490
|
+
const months = opts.from && opts.to
|
|
491
|
+
? (() => {
|
|
492
|
+
const result = [];
|
|
493
|
+
const [fy, fm] = opts.from.split('-').map(Number);
|
|
494
|
+
const [ty, tm] = opts.to.split('-').map(Number);
|
|
495
|
+
let y = fy, m = fm;
|
|
496
|
+
while (y < ty || (y === ty && m <= tm)) {
|
|
497
|
+
result.push(`${y}-${String(m).padStart(2, '0')}`);
|
|
498
|
+
m++;
|
|
499
|
+
if (m > 12) {
|
|
500
|
+
m = 1;
|
|
501
|
+
y++;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return result;
|
|
505
|
+
})()
|
|
506
|
+
: undefined;
|
|
507
|
+
const result = await detectRateAnomalies({
|
|
508
|
+
months,
|
|
509
|
+
threshold: parseFloat(opts.threshold),
|
|
510
|
+
});
|
|
511
|
+
console.log(`Found ${result.anomalies.length} anomalies (threshold: ${opts.threshold}σ)`);
|
|
512
|
+
for (const a of result.anomalies.slice(0, 30)) {
|
|
513
|
+
console.log(` ${a.month} | ${a.program_code ?? a.master_program} | z=${a.zscore.toFixed(2)} | rate=${a.rate_per_unit}`);
|
|
514
|
+
}
|
|
515
|
+
if (result.anomalies.length > 30)
|
|
516
|
+
console.log(` ... and ${result.anomalies.length - 30} more`);
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
console.error(`Anomaly detection failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
520
|
+
process.exit(1);
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
// ── Diagnose months ─────────────────────────────────────────────────
|
|
524
|
+
program
|
|
525
|
+
.command('diagnose-months')
|
|
526
|
+
.description('Run diagnostic checks on staging data for specified months')
|
|
527
|
+
.option('--months <csv>', 'Comma-separated YYYY-MM months (default: all)')
|
|
528
|
+
.action(async (opts) => {
|
|
529
|
+
const months = opts.months?.split(',').map(m => m.trim());
|
|
530
|
+
try {
|
|
531
|
+
const result = await diagnoseMonths(months ? { months } : undefined);
|
|
532
|
+
console.log(`Analysed months: ${result.monthsAnalysed.join(', ')}`);
|
|
533
|
+
console.log(`Total staging rows: ${result.totalRows} (median: ${result.medianRowCount}/month)\n`);
|
|
534
|
+
for (const issue of result.issues) {
|
|
535
|
+
console.log(` ✗ [${issue.kind}] ${issue.month ?? 'global'}: ${issue.message}`);
|
|
536
|
+
}
|
|
537
|
+
if (result.issues.length === 0) {
|
|
538
|
+
console.log(' ✓ No issues found');
|
|
539
|
+
}
|
|
540
|
+
console.log(`\nSummary: ${result.summary.totalIssues} issues found`);
|
|
541
|
+
}
|
|
542
|
+
catch (err) {
|
|
543
|
+
console.error(`Diagnosis failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
544
|
+
process.exit(1);
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
// ── Generate NetSuite template ──────────────────────────────────────
|
|
548
|
+
program
|
|
549
|
+
.command('generate-netsuite-template')
|
|
550
|
+
.description('Generate a blank NetSuite XLSX upload template')
|
|
551
|
+
.option('--output <path>', 'Output file path', 'netsuite-template.xlsx')
|
|
552
|
+
.action(async (opts) => {
|
|
553
|
+
try {
|
|
554
|
+
const result = await generateNetSuiteTemplate(opts.output);
|
|
555
|
+
console.log(`Template saved: ${result.outputPath} (${result.accountCount} accounts)`);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
console.error(`Template generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
// ── Distribute newsletter ───────────────────────────────────────────
|
|
563
|
+
program
|
|
564
|
+
.command('distribute-newsletter')
|
|
565
|
+
.description('Trigger newsletter distribution via n8n webhook')
|
|
566
|
+
.requiredOption('--document-id <id>', 'Strapi newsletter documentId')
|
|
567
|
+
.option('--channel <ch>', 'Distribution channel: email or all', 'all')
|
|
568
|
+
.action(async (opts) => {
|
|
569
|
+
try {
|
|
570
|
+
const result = await distributeNewsletter(opts.documentId, {
|
|
571
|
+
channel: opts.channel,
|
|
572
|
+
});
|
|
573
|
+
if (result.success) {
|
|
574
|
+
console.log(`Distribution triggered for ${opts.documentId} (channel: ${opts.channel})`);
|
|
575
|
+
}
|
|
576
|
+
else {
|
|
577
|
+
console.error(`Distribution failed: ${result.error}`);
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
catch (err) {
|
|
582
|
+
console.error(`Distribution failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
583
|
+
process.exit(1);
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
// ── Check distribution status ───────────────────────────────────────
|
|
587
|
+
program
|
|
588
|
+
.command('distribution-status')
|
|
589
|
+
.description('Check delivery status of a newsletter')
|
|
590
|
+
.requiredOption('--document-id <id>', 'Strapi newsletter documentId')
|
|
591
|
+
.action(async (opts) => {
|
|
592
|
+
const status = await checkDistributionStatus(opts.documentId);
|
|
593
|
+
console.log(`Status: ${status.delivery_status}`);
|
|
594
|
+
if (status.delivered_at)
|
|
595
|
+
console.log(`Delivered: ${status.delivered_at}`);
|
|
596
|
+
if (status.recipients_count)
|
|
597
|
+
console.log(`Recipients: ${status.recipients_count}`);
|
|
598
|
+
if (status.ghl_campaign_id)
|
|
599
|
+
console.log(`GHL Campaign: ${status.ghl_campaign_id}`);
|
|
600
|
+
});
|
|
601
|
+
// ── Generate social posts ───────────────────────────────────────────
|
|
602
|
+
program
|
|
603
|
+
.command('generate-social-posts')
|
|
604
|
+
.description('Generate AI-powered social media ad posts and push to Strapi')
|
|
605
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
606
|
+
.option('--count <n>', 'Number of posts to generate', '9')
|
|
607
|
+
.option('--week-of <date>', 'Week start date YYYY-MM-DD (default: next Monday)')
|
|
608
|
+
.option('--dry-run', 'Generate without pushing to Strapi', false)
|
|
609
|
+
.action(async (opts) => {
|
|
610
|
+
try {
|
|
611
|
+
const result = await generateSocialPosts({
|
|
612
|
+
brand: opts.brand,
|
|
613
|
+
count: parseInt(opts.count),
|
|
614
|
+
weekOf: opts.weekOf,
|
|
615
|
+
dryRun: opts.dryRun,
|
|
616
|
+
});
|
|
617
|
+
console.log(`Created ${result.postsCreated} posts for ${result.brand}`);
|
|
618
|
+
for (const p of result.posts) {
|
|
619
|
+
console.log(` ${p.scheduled_date} | ${p.platform} | ${p.headline}`);
|
|
620
|
+
}
|
|
621
|
+
if (result.errors.length > 0) {
|
|
622
|
+
console.log(`\nErrors: ${result.errors.join(', ')}`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
console.error(`Post generation failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
627
|
+
process.exit(1);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
// ── Publish social posts ────────────────────────────────────────────
|
|
631
|
+
program
|
|
632
|
+
.command('publish-social-posts')
|
|
633
|
+
.description('Publish pending social posts to platforms via n8n')
|
|
634
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
635
|
+
.option('--limit <n>', 'Max posts to publish')
|
|
636
|
+
.option('--dry-run', 'Preview without publishing', false)
|
|
637
|
+
.option('--retry', 'Retry previously failed posts', false)
|
|
638
|
+
.action(async (opts) => {
|
|
639
|
+
try {
|
|
640
|
+
let result;
|
|
641
|
+
if (opts.retry) {
|
|
642
|
+
result = await retryFailed(opts.brand);
|
|
643
|
+
}
|
|
644
|
+
else {
|
|
645
|
+
result = await publishSocialPosts({
|
|
646
|
+
brand: opts.brand,
|
|
647
|
+
limit: opts.limit ? parseInt(opts.limit) : undefined,
|
|
648
|
+
dryRun: opts.dryRun,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
console.log(`Published: ${result.published} | Failed: ${result.failed} | Skipped: ${result.skipped}`);
|
|
652
|
+
for (const d of result.details) {
|
|
653
|
+
console.log(` ${d.status} | ${d.headline}${d.error ? ` — ${d.error}` : ''}`);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
658
|
+
process.exit(1);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
// ── Social post queue ───────────────────────────────────────────────
|
|
662
|
+
program
|
|
663
|
+
.command('social-queue')
|
|
664
|
+
.description('View pending social posts ready for publishing')
|
|
665
|
+
.requiredOption('--brand <brand>', 'Brand: CRE-11TRUST or LIFEINSUR')
|
|
666
|
+
.action(async (opts) => {
|
|
667
|
+
const queue = await getPublishQueue(opts.brand);
|
|
668
|
+
if (queue.length === 0) {
|
|
669
|
+
console.log('No posts in queue');
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
console.log('| Date | Platform | Headline |');
|
|
673
|
+
console.log('|------|----------|----------|');
|
|
674
|
+
for (const p of queue) {
|
|
675
|
+
console.log(`| ${p.scheduled_date} | ${p.platform} | ${p.headline} |`);
|
|
676
|
+
}
|
|
677
|
+
console.log(`\n${queue.length} posts queued`);
|
|
678
|
+
});
|
|
679
|
+
// ── Publish blog ────────────────────────────────────────────────────
|
|
680
|
+
program
|
|
681
|
+
.command('publish-blog')
|
|
682
|
+
.description('Publish a Strapi blog post and optionally deploy portfolio site')
|
|
683
|
+
.requiredOption('--slug <slug>', 'Blog post slug')
|
|
684
|
+
.option('--deploy', 'Deploy portfolio site after publishing', false)
|
|
685
|
+
.action(async (opts) => {
|
|
686
|
+
try {
|
|
687
|
+
const result = await publishBlog({ slug: opts.slug, deployAfter: opts.deploy });
|
|
688
|
+
console.log(`Published: ${result.slug} (${result.documentId})`);
|
|
689
|
+
if (result.deployUrl)
|
|
690
|
+
console.log(`Deployed: ${result.deployUrl}`);
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
console.error(`Publish failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
});
|
|
697
|
+
// ── Blog drafts ─────────────────────────────────────────────────────
|
|
698
|
+
program
|
|
699
|
+
.command('blog-drafts')
|
|
700
|
+
.description('List unpublished blog post drafts')
|
|
701
|
+
.option('--site <site>', 'Filter by site (portfolio, insurance)')
|
|
702
|
+
.action(async (opts) => {
|
|
703
|
+
const drafts = await listBlogDrafts(opts.site);
|
|
704
|
+
if (drafts.length === 0) {
|
|
705
|
+
console.log('No drafts found');
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
console.log('| Created | Site | Title | Slug |');
|
|
709
|
+
console.log('|---------|------|-------|------|');
|
|
710
|
+
for (const d of drafts) {
|
|
711
|
+
console.log(`| ${d.createdAt.slice(0, 10)} | ${d.site} | ${d.title} | ${d.slug} |`);
|
|
712
|
+
}
|
|
713
|
+
});
|
|
714
|
+
// ── Database migration ──────────────────────────────────────────────
|
|
715
|
+
const migrate = program.command('migrate').description('Supabase database migration operations');
|
|
716
|
+
migrate
|
|
717
|
+
.command('push')
|
|
718
|
+
.description('Run supabase db push --linked on a target project')
|
|
719
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
720
|
+
.option('--dry-run', 'Preview without applying', false)
|
|
721
|
+
.action(async (opts) => {
|
|
722
|
+
const target = opts.target;
|
|
723
|
+
if (target !== 'returnpro' && target !== 'optimalos') {
|
|
724
|
+
console.error('Target must be "returnpro" or "optimalos"');
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
console.log(`Migrating ${target}${opts.dryRun ? ' (dry run)' : ''}...`);
|
|
728
|
+
const result = await migrateDb({ target, dryRun: opts.dryRun });
|
|
729
|
+
if (result.success) {
|
|
730
|
+
console.log(result.output);
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
console.error(`Migration failed:\n${result.errors}`);
|
|
734
|
+
process.exit(1);
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
migrate
|
|
738
|
+
.command('pending')
|
|
739
|
+
.description('List pending migration files')
|
|
740
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
741
|
+
.action(async (opts) => {
|
|
742
|
+
const files = await listPendingMigrations(opts.target);
|
|
743
|
+
if (files.length === 0) {
|
|
744
|
+
console.log('No migration files found');
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
for (const f of files)
|
|
748
|
+
console.log(` ${f}`);
|
|
749
|
+
console.log(`\n${files.length} migration files`);
|
|
750
|
+
});
|
|
751
|
+
migrate
|
|
752
|
+
.command('create')
|
|
753
|
+
.description('Create a new empty migration file')
|
|
754
|
+
.requiredOption('--target <t>', 'Target: returnpro or optimalos')
|
|
755
|
+
.requiredOption('--name <name>', 'Migration name')
|
|
756
|
+
.action(async (opts) => {
|
|
757
|
+
const path = await createMigration(opts.target, opts.name);
|
|
758
|
+
console.log(`Created: ${path}`);
|
|
759
|
+
});
|
|
760
|
+
// ── Budget scenarios ────────────────────────────────────────────────
|
|
761
|
+
const scenario = program.command('scenario').description('Budget scenario management');
|
|
762
|
+
scenario
|
|
763
|
+
.command('save')
|
|
764
|
+
.description('Save current projections as a named scenario')
|
|
765
|
+
.requiredOption('--name <name>', 'Scenario name')
|
|
766
|
+
.requiredOption('--adjustment-type <type>', 'Adjustment type: percentage or flat')
|
|
767
|
+
.requiredOption('--adjustment-value <n>', 'Adjustment value')
|
|
768
|
+
.option('--description <desc>', 'Description')
|
|
769
|
+
.option('--fiscal-year <fy>', 'Fiscal year', '2025')
|
|
770
|
+
.option('--user-id <uuid>', 'User UUID')
|
|
771
|
+
.action(async (opts) => {
|
|
772
|
+
try {
|
|
773
|
+
const path = await saveScenario({
|
|
774
|
+
name: opts.name,
|
|
775
|
+
adjustmentType: opts.adjustmentType,
|
|
776
|
+
adjustmentValue: parseFloat(opts.adjustmentValue),
|
|
777
|
+
fiscalYear: parseInt(opts.fiscalYear),
|
|
778
|
+
userId: opts.userId,
|
|
779
|
+
description: opts.description,
|
|
780
|
+
});
|
|
781
|
+
console.log(`Scenario saved: ${path}`);
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
console.error(`Save failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
785
|
+
process.exit(1);
|
|
786
|
+
}
|
|
787
|
+
});
|
|
788
|
+
scenario
|
|
789
|
+
.command('list')
|
|
790
|
+
.description('List all saved budget scenarios')
|
|
791
|
+
.action(async () => {
|
|
792
|
+
const scenarios = await listScenarios();
|
|
793
|
+
if (scenarios.length === 0) {
|
|
794
|
+
console.log('No scenarios saved');
|
|
795
|
+
return;
|
|
796
|
+
}
|
|
797
|
+
console.log('| Name | Adjustment | Projected | Change | Created |');
|
|
798
|
+
console.log('|------|------------|-----------|--------|---------|');
|
|
799
|
+
for (const s of scenarios) {
|
|
800
|
+
const adj = s.adjustmentType === 'percentage' ? `${s.adjustmentValue}%` : `+${s.adjustmentValue}`;
|
|
801
|
+
console.log(`| ${s.name} | ${adj} | ${s.totalProjected.toLocaleString()} | ${s.percentageChange.toFixed(1)}% | ${s.createdAt.slice(0, 10)} |`);
|
|
802
|
+
}
|
|
803
|
+
});
|
|
804
|
+
scenario
|
|
805
|
+
.command('compare')
|
|
806
|
+
.description('Compare two or more scenarios side by side')
|
|
807
|
+
.requiredOption('--names <csv>', 'Comma-separated scenario names')
|
|
808
|
+
.action(async (opts) => {
|
|
809
|
+
const names = opts.names.split(',').map(n => n.trim());
|
|
810
|
+
if (names.length < 2) {
|
|
811
|
+
console.error('Need at least 2 scenario names to compare');
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
try {
|
|
815
|
+
const result = await compareScenarios(names);
|
|
816
|
+
// Print header
|
|
817
|
+
const header = ['Program', 'Actual', ...result.scenarioNames].join(' | ');
|
|
818
|
+
console.log(`| ${header} |`);
|
|
819
|
+
console.log(`|${result.scenarioNames.map(() => '---').concat(['---', '---']).join('|')}|`);
|
|
820
|
+
for (const p of result.programs.slice(0, 50)) {
|
|
821
|
+
const vals = result.scenarioNames.map(n => String(p.projectedByScenario[n] ?? 0));
|
|
822
|
+
console.log(`| ${p.programCode} | ${p.actual} | ${vals.join(' | ')} |`);
|
|
823
|
+
}
|
|
824
|
+
// Totals
|
|
825
|
+
console.log('\nTotals:');
|
|
826
|
+
for (const name of result.scenarioNames) {
|
|
827
|
+
const t = result.totalsByScenario[name];
|
|
828
|
+
console.log(` ${name}: ${t.totalProjected.toLocaleString()} (${t.percentageChange >= 0 ? '+' : ''}${t.percentageChange.toFixed(1)}%)`);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
console.error(`Compare failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
833
|
+
process.exit(1);
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
scenario
|
|
837
|
+
.command('delete')
|
|
838
|
+
.description('Delete a saved scenario')
|
|
839
|
+
.requiredOption('--name <name>', 'Scenario name')
|
|
840
|
+
.action(async (opts) => {
|
|
841
|
+
try {
|
|
842
|
+
await deleteScenario(opts.name);
|
|
843
|
+
console.log(`Deleted scenario: ${opts.name}`);
|
|
844
|
+
}
|
|
845
|
+
catch (err) {
|
|
846
|
+
console.error(`Delete failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
// ── Delete batch ────────────────────────────────────────────────────
|
|
851
|
+
program
|
|
852
|
+
.command('delete-batch')
|
|
853
|
+
.description('Batch delete transactions or staging rows (safe: dry-run by default)')
|
|
854
|
+
.requiredOption('--table <t>', 'Table: transactions or stg_financials_raw')
|
|
855
|
+
.option('--user-id <uuid>', 'User UUID filter')
|
|
856
|
+
.option('--date-from <date>', 'Start date YYYY-MM-DD')
|
|
857
|
+
.option('--date-to <date>', 'End date YYYY-MM-DD')
|
|
858
|
+
.option('--source <src>', 'Source filter')
|
|
859
|
+
.option('--category <cat>', 'Category filter (transactions)')
|
|
860
|
+
.option('--account-code <code>', 'Account code filter (staging)')
|
|
861
|
+
.option('--month <YYYY-MM>', 'Month filter (staging)')
|
|
862
|
+
.option('--execute', 'Actually delete (default is dry-run preview)', false)
|
|
863
|
+
.action(async (opts) => {
|
|
864
|
+
const table = opts.table;
|
|
865
|
+
const filters = {
|
|
866
|
+
dateFrom: opts.dateFrom,
|
|
867
|
+
dateTo: opts.dateTo,
|
|
868
|
+
source: opts.source,
|
|
869
|
+
category: opts.category,
|
|
870
|
+
accountCode: opts.accountCode,
|
|
871
|
+
month: opts.month,
|
|
872
|
+
};
|
|
873
|
+
const dryRun = !opts.execute;
|
|
874
|
+
if (dryRun) {
|
|
875
|
+
const preview = await previewBatch({ table, userId: opts.userId, filters });
|
|
876
|
+
console.log(`Preview: ${preview.matchCount} rows would be deleted from ${table}`);
|
|
877
|
+
if (Object.keys(preview.groupedCounts).length > 0) {
|
|
878
|
+
console.log('\nGrouped:');
|
|
879
|
+
for (const [key, count] of Object.entries(preview.groupedCounts)) {
|
|
880
|
+
console.log(` ${key}: ${count}`);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (preview.sample.length > 0) {
|
|
884
|
+
console.log(`\nSample (first ${preview.sample.length}):`);
|
|
885
|
+
for (const row of preview.sample) {
|
|
886
|
+
console.log(` ${JSON.stringify(row)}`);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
console.log('\nUse --execute to actually delete');
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
const result = await deleteBatch({ table, userId: opts.userId, filters, dryRun: false });
|
|
893
|
+
console.log(`Deleted ${result.deletedCount} rows from ${table}`);
|
|
894
|
+
}
|
|
895
|
+
});
|
|
896
|
+
// ── Config commands ─────────────────────────────────────────────────
|
|
897
|
+
const config = program.command('config').description('OpenClaw config sync operations');
|
|
898
|
+
config
|
|
899
|
+
.command('push')
|
|
900
|
+
.description('Push local openclaw.json to cloud storage')
|
|
901
|
+
.requiredOption('--agent <name>', 'Agent name (e.g., oracle, opal, kimklaw)')
|
|
902
|
+
.action(async (opts) => {
|
|
903
|
+
try {
|
|
904
|
+
const result = await pushConfig(opts.agent);
|
|
905
|
+
console.log(`✓ Config pushed for ${opts.agent}`);
|
|
906
|
+
console.log(` ID: ${result.id}`);
|
|
907
|
+
console.log(` Version: ${result.version}`);
|
|
908
|
+
}
|
|
909
|
+
catch (err) {
|
|
910
|
+
console.error(`Push failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
911
|
+
process.exit(1);
|
|
912
|
+
}
|
|
913
|
+
});
|
|
914
|
+
config
|
|
915
|
+
.command('pull')
|
|
916
|
+
.description('Pull config from cloud and save to local openclaw.json')
|
|
917
|
+
.requiredOption('--agent <name>', 'Agent name to pull config for')
|
|
918
|
+
.action(async (opts) => {
|
|
919
|
+
try {
|
|
920
|
+
const result = await pullConfig(opts.agent);
|
|
921
|
+
console.log(`✓ Config pulled for ${result.agent_name}`);
|
|
922
|
+
console.log(` Version: ${result.version}`);
|
|
923
|
+
console.log(` Updated: ${result.updated_at}`);
|
|
924
|
+
console.log(`\nSaved to: ~/.openclaw/openclaw.json`);
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
console.error(`Pull failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
928
|
+
process.exit(1);
|
|
929
|
+
}
|
|
930
|
+
});
|
|
931
|
+
config
|
|
932
|
+
.command('list')
|
|
933
|
+
.description('List all saved agent configs in cloud')
|
|
934
|
+
.action(async () => {
|
|
935
|
+
try {
|
|
936
|
+
const configs = await listConfigs();
|
|
937
|
+
if (configs.length === 0) {
|
|
938
|
+
console.log('No configs found in cloud storage');
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
console.log('| Agent | Version | Updated |');
|
|
942
|
+
console.log('|-------|---------|---------|');
|
|
943
|
+
for (const c of configs) {
|
|
944
|
+
console.log(`| ${c.agent_name} | ${c.version.slice(0, 19)} | ${c.updated_at.slice(0, 19)} |`);
|
|
945
|
+
}
|
|
946
|
+
console.log(`\nTotal: ${configs.length} configs`);
|
|
947
|
+
}
|
|
948
|
+
catch (err) {
|
|
949
|
+
console.error(`List failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
950
|
+
process.exit(1);
|
|
951
|
+
}
|
|
952
|
+
});
|
|
953
|
+
config
|
|
954
|
+
.command('diff')
|
|
955
|
+
.description('Compare local config with cloud version')
|
|
956
|
+
.requiredOption('--agent <name>', 'Agent name to compare')
|
|
957
|
+
.action(async (opts) => {
|
|
958
|
+
try {
|
|
959
|
+
const { local, cloud, differences } = await diffConfig(opts.agent);
|
|
960
|
+
if (differences.length === 0) {
|
|
961
|
+
console.log('✓ Local and cloud configs are in sync');
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
console.log('Differences found:');
|
|
965
|
+
for (const d of differences) {
|
|
966
|
+
console.log(` • ${d}`);
|
|
967
|
+
}
|
|
968
|
+
if (local?.meta?.lastTouchedAt) {
|
|
969
|
+
console.log(`\nLocal updated: ${local.meta.lastTouchedAt}`);
|
|
970
|
+
}
|
|
971
|
+
if (cloud?.updated_at) {
|
|
972
|
+
console.log(`Cloud updated: ${cloud.updated_at}`);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
catch (err) {
|
|
976
|
+
console.error(`Diff failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
977
|
+
process.exit(1);
|
|
978
|
+
}
|
|
979
|
+
});
|
|
980
|
+
config
|
|
981
|
+
.command('sync')
|
|
982
|
+
.description('Two-way sync: push if local is newer, pull if cloud is newer')
|
|
983
|
+
.requiredOption('--agent <name>', 'Agent name to sync')
|
|
984
|
+
.action(async (opts) => {
|
|
985
|
+
try {
|
|
986
|
+
const result = await syncConfig(opts.agent);
|
|
987
|
+
console.log(`✓ ${result.action}`);
|
|
988
|
+
console.log(` ${result.message}`);
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
console.error(`Sync failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
992
|
+
process.exit(1);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
program.parseAsync();
|