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