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.
Files changed (185) hide show
  1. package/.claude-plugin/marketplace.json +18 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/.env.example +17 -0
  4. package/CLAUDE.md +67 -0
  5. package/COMMANDS.md +264 -0
  6. package/PUBLISH.md +70 -0
  7. package/agents/content-ops.md +2 -2
  8. package/agents/financial-ops.md +2 -2
  9. package/agents/infra-ops.md +2 -2
  10. package/apps/.gitkeep +0 -0
  11. package/bin/optimal.ts +1418 -0
  12. package/docs/MIGRATION_NEEDED.md +37 -0
  13. package/docs/plans/.gitkeep +0 -0
  14. package/docs/plans/optimal-cli-config-registry-v1.md +71 -0
  15. package/hooks/.gitkeep +0 -0
  16. package/lib/budget/projections.ts +561 -0
  17. package/lib/budget/scenarios.ts +312 -0
  18. package/lib/cms/publish-blog.ts +129 -0
  19. package/lib/cms/strapi-client.ts +302 -0
  20. package/lib/config/registry.ts +229 -0
  21. package/lib/config/schema.ts +58 -0
  22. package/lib/config.ts +247 -0
  23. package/lib/infra/.gitkeep +0 -0
  24. package/lib/infra/deploy.ts +70 -0
  25. package/lib/infra/migrate.ts +141 -0
  26. package/lib/kanban-obsidian.ts +232 -0
  27. package/lib/kanban-sync.ts +258 -0
  28. package/lib/kanban.ts +239 -0
  29. package/lib/newsletter/.gitkeep +0 -0
  30. package/lib/newsletter/distribute.ts +256 -0
  31. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  32. package/lib/newsletter/generate.ts +735 -0
  33. package/lib/obsidian-tasks.ts +231 -0
  34. package/lib/returnpro/.gitkeep +0 -0
  35. package/lib/returnpro/anomalies.ts +258 -0
  36. package/lib/returnpro/audit.ts +194 -0
  37. package/lib/returnpro/diagnose.ts +400 -0
  38. package/lib/returnpro/kpis.ts +255 -0
  39. package/lib/returnpro/templates.ts +323 -0
  40. package/lib/returnpro/upload-income.ts +311 -0
  41. package/lib/returnpro/upload-netsuite.ts +696 -0
  42. package/lib/returnpro/upload-r1.ts +563 -0
  43. package/lib/social/post-generator.ts +468 -0
  44. package/lib/social/publish.ts +301 -0
  45. package/lib/social/scraper.ts +503 -0
  46. package/lib/supabase.ts +25 -0
  47. package/lib/transactions/delete-batch.ts +258 -0
  48. package/lib/transactions/ingest.ts +659 -0
  49. package/lib/transactions/stamp.ts +654 -0
  50. package/package.json +5 -18
  51. package/pnpm-workspace.yaml +3 -0
  52. package/scripts/check-table.ts +24 -0
  53. package/scripts/create-tables.ts +94 -0
  54. package/scripts/migrate-kanban.sh +28 -0
  55. package/scripts/migrate-v2.ts +78 -0
  56. package/scripts/migrate.ts +79 -0
  57. package/scripts/run-migration.ts +59 -0
  58. package/scripts/seed-board.ts +203 -0
  59. package/scripts/test-kanban.ts +21 -0
  60. package/skills/audit-financials/SKILL.md +33 -0
  61. package/skills/board-create/SKILL.md +28 -0
  62. package/skills/board-update/SKILL.md +27 -0
  63. package/skills/board-view/SKILL.md +27 -0
  64. package/skills/delete-batch/SKILL.md +77 -0
  65. package/skills/deploy/SKILL.md +40 -0
  66. package/skills/diagnose-months/SKILL.md +68 -0
  67. package/skills/distribute-newsletter/SKILL.md +58 -0
  68. package/skills/export-budget/SKILL.md +44 -0
  69. package/skills/export-kpis/SKILL.md +52 -0
  70. package/skills/generate-netsuite-template/SKILL.md +51 -0
  71. package/skills/generate-newsletter/SKILL.md +53 -0
  72. package/skills/generate-newsletter-insurance/SKILL.md +59 -0
  73. package/skills/generate-social-posts/SKILL.md +67 -0
  74. package/skills/health-check/SKILL.md +42 -0
  75. package/skills/ingest-transactions/SKILL.md +51 -0
  76. package/skills/manage-cms/SKILL.md +50 -0
  77. package/skills/manage-scenarios/SKILL.md +83 -0
  78. package/skills/migrate-db/SKILL.md +79 -0
  79. package/skills/preview-newsletter/SKILL.md +50 -0
  80. package/skills/project-budget/SKILL.md +60 -0
  81. package/skills/publish-blog/SKILL.md +70 -0
  82. package/skills/publish-social-posts/SKILL.md +70 -0
  83. package/skills/rate-anomalies/SKILL.md +62 -0
  84. package/skills/scrape-ads/SKILL.md +49 -0
  85. package/skills/stamp-transactions/SKILL.md +62 -0
  86. package/skills/upload-income-statements/SKILL.md +54 -0
  87. package/skills/upload-netsuite/SKILL.md +56 -0
  88. package/skills/upload-r1/SKILL.md +45 -0
  89. package/supabase/.temp/cli-latest +1 -0
  90. package/supabase/migrations/.gitkeep +0 -0
  91. package/supabase/migrations/20250305000001_create_agent_configs.sql +36 -0
  92. package/supabase/migrations/20260305111300_create_cli_config_registry.sql +22 -0
  93. package/supabase/migrations/20260306195000_create_kanban_tables.sql +97 -0
  94. package/tests/config-command-smoke.test.ts +395 -0
  95. package/tests/config-registry.test.ts +173 -0
  96. package/tsconfig.json +19 -0
  97. package/agents/profiles.json +0 -5
  98. package/dist/bin/optimal.d.ts +0 -2
  99. package/dist/bin/optimal.js +0 -1590
  100. package/dist/lib/assets/index.d.ts +0 -79
  101. package/dist/lib/assets/index.js +0 -153
  102. package/dist/lib/assets.d.ts +0 -20
  103. package/dist/lib/assets.js +0 -112
  104. package/dist/lib/auth/index.d.ts +0 -83
  105. package/dist/lib/auth/index.js +0 -146
  106. package/dist/lib/board/index.d.ts +0 -39
  107. package/dist/lib/board/index.js +0 -285
  108. package/dist/lib/board/types.d.ts +0 -111
  109. package/dist/lib/board/types.js +0 -1
  110. package/dist/lib/bot/claim.d.ts +0 -3
  111. package/dist/lib/bot/claim.js +0 -20
  112. package/dist/lib/bot/coordinator.d.ts +0 -27
  113. package/dist/lib/bot/coordinator.js +0 -178
  114. package/dist/lib/bot/heartbeat.d.ts +0 -6
  115. package/dist/lib/bot/heartbeat.js +0 -30
  116. package/dist/lib/bot/index.d.ts +0 -9
  117. package/dist/lib/bot/index.js +0 -6
  118. package/dist/lib/bot/protocol.d.ts +0 -12
  119. package/dist/lib/bot/protocol.js +0 -74
  120. package/dist/lib/bot/reporter.d.ts +0 -3
  121. package/dist/lib/bot/reporter.js +0 -27
  122. package/dist/lib/bot/skills.d.ts +0 -26
  123. package/dist/lib/bot/skills.js +0 -69
  124. package/dist/lib/budget/projections.d.ts +0 -115
  125. package/dist/lib/budget/projections.js +0 -384
  126. package/dist/lib/budget/scenarios.d.ts +0 -93
  127. package/dist/lib/budget/scenarios.js +0 -214
  128. package/dist/lib/cms/publish-blog.d.ts +0 -62
  129. package/dist/lib/cms/publish-blog.js +0 -74
  130. package/dist/lib/cms/strapi-client.d.ts +0 -123
  131. package/dist/lib/cms/strapi-client.js +0 -213
  132. package/dist/lib/config/registry.d.ts +0 -17
  133. package/dist/lib/config/registry.js +0 -182
  134. package/dist/lib/config/schema.d.ts +0 -31
  135. package/dist/lib/config/schema.js +0 -25
  136. package/dist/lib/config.d.ts +0 -55
  137. package/dist/lib/config.js +0 -206
  138. package/dist/lib/errors.d.ts +0 -25
  139. package/dist/lib/errors.js +0 -91
  140. package/dist/lib/format.d.ts +0 -28
  141. package/dist/lib/format.js +0 -98
  142. package/dist/lib/infra/deploy.d.ts +0 -29
  143. package/dist/lib/infra/deploy.js +0 -58
  144. package/dist/lib/infra/migrate.d.ts +0 -34
  145. package/dist/lib/infra/migrate.js +0 -103
  146. package/dist/lib/newsletter/distribute.d.ts +0 -52
  147. package/dist/lib/newsletter/distribute.js +0 -193
  148. package/dist/lib/newsletter/generate-insurance.js +0 -36
  149. package/dist/lib/newsletter/generate.d.ts +0 -104
  150. package/dist/lib/newsletter/generate.js +0 -571
  151. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  152. package/dist/lib/returnpro/anomalies.js +0 -166
  153. package/dist/lib/returnpro/audit.d.ts +0 -32
  154. package/dist/lib/returnpro/audit.js +0 -147
  155. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  156. package/dist/lib/returnpro/diagnose.js +0 -281
  157. package/dist/lib/returnpro/kpis.d.ts +0 -32
  158. package/dist/lib/returnpro/kpis.js +0 -192
  159. package/dist/lib/returnpro/templates.d.ts +0 -48
  160. package/dist/lib/returnpro/templates.js +0 -229
  161. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  162. package/dist/lib/returnpro/upload-income.js +0 -235
  163. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  164. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  165. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  166. package/dist/lib/returnpro/upload-r1.js +0 -398
  167. package/dist/lib/returnpro/validate.d.ts +0 -37
  168. package/dist/lib/returnpro/validate.js +0 -124
  169. package/dist/lib/social/meta.d.ts +0 -90
  170. package/dist/lib/social/meta.js +0 -160
  171. package/dist/lib/social/post-generator.d.ts +0 -83
  172. package/dist/lib/social/post-generator.js +0 -333
  173. package/dist/lib/social/publish.d.ts +0 -66
  174. package/dist/lib/social/publish.js +0 -226
  175. package/dist/lib/social/scraper.d.ts +0 -67
  176. package/dist/lib/social/scraper.js +0 -361
  177. package/dist/lib/supabase.d.ts +0 -4
  178. package/dist/lib/supabase.js +0 -20
  179. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  180. package/dist/lib/transactions/delete-batch.js +0 -203
  181. package/dist/lib/transactions/ingest.d.ts +0 -43
  182. package/dist/lib/transactions/ingest.js +0 -555
  183. package/dist/lib/transactions/stamp.d.ts +0 -51
  184. package/dist/lib/transactions/stamp.js +0 -524
  185. package/docs/CLI-REFERENCE.md +0 -361
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Budget scenario manager — save, load, list, compare, and delete named scenarios.
3
+ *
4
+ * Scenarios are stored as JSON files on disk at:
5
+ * /home/optimal/optimal-cli/data/scenarios/{name}.json
6
+ *
7
+ * Each scenario captures a full snapshot of projected units after applying
8
+ * a uniform adjustment to the live fpa_wes_imports data.
9
+ */
10
+
11
+ import 'dotenv/config'
12
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'node:fs'
13
+ import { join, dirname } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import {
16
+ fetchWesImports,
17
+ initializeProjections,
18
+ applyUniformAdjustment,
19
+ calculateTotals,
20
+ } from './projections.js'
21
+
22
+ // --- Directory resolution ---
23
+
24
+ const __filename = fileURLToPath(import.meta.url)
25
+ const __dirname = dirname(__filename)
26
+
27
+ // Resolve relative to the repo root (lib/budget/ -> ../../data/scenarios/)
28
+ const SCENARIOS_DIR = join(__dirname, '..', '..', 'data', 'scenarios')
29
+
30
+ function ensureScenariosDir(): void {
31
+ mkdirSync(SCENARIOS_DIR, { recursive: true })
32
+ }
33
+
34
+ // --- Types ---
35
+
36
+ export interface SaveScenarioOptions {
37
+ name: string
38
+ adjustmentType: 'percentage' | 'flat'
39
+ adjustmentValue: number
40
+ fiscalYear?: number
41
+ userId?: string
42
+ description?: string
43
+ }
44
+
45
+ export interface ScenarioData {
46
+ name: string
47
+ createdAt: string
48
+ adjustmentType: 'percentage' | 'flat'
49
+ adjustmentValue: number
50
+ description?: string
51
+ projections: Array<{
52
+ programCode: string
53
+ masterProgram: string
54
+ actualUnits: number
55
+ projectedUnits: number
56
+ }>
57
+ totals: {
58
+ totalActual: number
59
+ totalProjected: number
60
+ percentageChange: number
61
+ }
62
+ }
63
+
64
+ export interface ScenarioSummary {
65
+ name: string
66
+ createdAt: string
67
+ adjustmentType: string
68
+ adjustmentValue: number
69
+ description?: string
70
+ totalProjected: number
71
+ percentageChange: number
72
+ }
73
+
74
+ export interface ComparisonResult {
75
+ scenarioNames: string[]
76
+ programs: Array<{
77
+ programCode: string
78
+ masterProgram: string
79
+ actual: number
80
+ projectedByScenario: Record<string, number>
81
+ }>
82
+ totalsByScenario: Record<string, { totalProjected: number; percentageChange: number }>
83
+ }
84
+
85
+ // --- Helpers ---
86
+
87
+ /**
88
+ * Sanitize a scenario name into a safe filename segment.
89
+ * Lowercases, replaces spaces and disallowed chars with hyphens,
90
+ * collapses repeated hyphens, and strips leading/trailing hyphens.
91
+ */
92
+ function sanitizeName(name: string): string {
93
+ return name
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9-_]+/g, '-')
96
+ .replace(/-{2,}/g, '-')
97
+ .replace(/^-|-$/g, '')
98
+ }
99
+
100
+ function scenarioPath(sanitized: string): string {
101
+ return join(SCENARIOS_DIR, `${sanitized}.json`)
102
+ }
103
+
104
+ // --- Public API ---
105
+
106
+ /**
107
+ * Save current projections as a named scenario to disk.
108
+ *
109
+ * Fetches live data via fetchWesImports, applies the given adjustment,
110
+ * calculates totals, and writes the result as JSON.
111
+ *
112
+ * @returns The absolute path to the saved scenario file.
113
+ */
114
+ export async function saveScenario(opts: SaveScenarioOptions): Promise<string> {
115
+ ensureScenariosDir()
116
+
117
+ const sanitized = sanitizeName(opts.name)
118
+ if (!sanitized) {
119
+ throw new Error(`Invalid scenario name: "${opts.name}"`)
120
+ }
121
+
122
+ // Fetch and process data
123
+ const summary = await fetchWesImports({
124
+ fiscalYear: opts.fiscalYear,
125
+ userId: opts.userId,
126
+ })
127
+
128
+ const initialized = initializeProjections(summary)
129
+ const adjusted = applyUniformAdjustment(
130
+ initialized,
131
+ opts.adjustmentType,
132
+ opts.adjustmentValue,
133
+ )
134
+ const totals = calculateTotals(adjusted)
135
+
136
+ const scenarioData: ScenarioData = {
137
+ name: opts.name,
138
+ createdAt: new Date().toISOString(),
139
+ adjustmentType: opts.adjustmentType,
140
+ adjustmentValue: opts.adjustmentValue,
141
+ ...(opts.description !== undefined ? { description: opts.description } : {}),
142
+ projections: adjusted.map((p) => ({
143
+ programCode: p.programCode,
144
+ masterProgram: p.masterProgram,
145
+ actualUnits: p.actualUnits,
146
+ projectedUnits: p.projectedUnits,
147
+ })),
148
+ totals: {
149
+ totalActual: totals.totalActual,
150
+ totalProjected: totals.totalProjected,
151
+ percentageChange: totals.percentageChange,
152
+ },
153
+ }
154
+
155
+ const filePath = scenarioPath(sanitized)
156
+ writeFileSync(filePath, JSON.stringify(scenarioData, null, 2), 'utf-8')
157
+ return filePath
158
+ }
159
+
160
+ /**
161
+ * Load a saved scenario from disk by name.
162
+ *
163
+ * Accepts the original name (will be sanitized) or the sanitized form.
164
+ */
165
+ export async function loadScenario(name: string): Promise<ScenarioData> {
166
+ const sanitized = sanitizeName(name)
167
+ const filePath = scenarioPath(sanitized)
168
+
169
+ let raw: string
170
+ try {
171
+ raw = readFileSync(filePath, 'utf-8')
172
+ } catch {
173
+ throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`)
174
+ }
175
+
176
+ return JSON.parse(raw) as ScenarioData
177
+ }
178
+
179
+ /**
180
+ * List all saved scenarios, returning lightweight summary objects.
181
+ *
182
+ * Scenarios with unreadable or malformed files are silently skipped.
183
+ */
184
+ export async function listScenarios(): Promise<ScenarioSummary[]> {
185
+ ensureScenariosDir()
186
+
187
+ let files: string[]
188
+ try {
189
+ files = readdirSync(SCENARIOS_DIR)
190
+ } catch {
191
+ return []
192
+ }
193
+
194
+ const jsonFiles = files.filter((f) => f.endsWith('.json'))
195
+ const summaries: ScenarioSummary[] = []
196
+
197
+ for (const file of jsonFiles) {
198
+ const filePath = join(SCENARIOS_DIR, file)
199
+ try {
200
+ const raw = readFileSync(filePath, 'utf-8')
201
+ const data = JSON.parse(raw) as ScenarioData
202
+ summaries.push({
203
+ name: data.name,
204
+ createdAt: data.createdAt,
205
+ adjustmentType: data.adjustmentType,
206
+ adjustmentValue: data.adjustmentValue,
207
+ ...(data.description !== undefined ? { description: data.description } : {}),
208
+ totalProjected: data.totals.totalProjected,
209
+ percentageChange: data.totals.percentageChange,
210
+ })
211
+ } catch {
212
+ // Skip unreadable/malformed scenario files
213
+ }
214
+ }
215
+
216
+ // Sort newest first
217
+ summaries.sort(
218
+ (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
219
+ )
220
+
221
+ return summaries
222
+ }
223
+
224
+ /**
225
+ * Compare two or more scenarios side by side.
226
+ *
227
+ * For each program that appears in any of the loaded scenarios, the result
228
+ * includes the actual unit count and the projected units from each scenario.
229
+ * Programs missing from a given scenario will have projectedUnits of 0.
230
+ */
231
+ export async function compareScenarios(names: string[]): Promise<ComparisonResult> {
232
+ if (names.length < 2) {
233
+ throw new Error('compareScenarios requires at least 2 scenario names')
234
+ }
235
+
236
+ // Load all scenarios in parallel
237
+ const loaded = await Promise.all(names.map((n) => loadScenario(n)))
238
+
239
+ // Build a unified map of programCode -> { masterProgram, actual, projectedByScenario }
240
+ const programMap = new Map<
241
+ string,
242
+ {
243
+ masterProgram: string
244
+ actual: number
245
+ projectedByScenario: Record<string, number>
246
+ }
247
+ >()
248
+
249
+ for (const scenario of loaded) {
250
+ for (const p of scenario.projections) {
251
+ const existing = programMap.get(p.programCode)
252
+ if (existing) {
253
+ existing.projectedByScenario[scenario.name] = p.projectedUnits
254
+ // Keep the actual from whichever scenario we see first
255
+ } else {
256
+ programMap.set(p.programCode, {
257
+ masterProgram: p.masterProgram,
258
+ actual: p.actualUnits,
259
+ projectedByScenario: { [scenario.name]: p.projectedUnits },
260
+ })
261
+ }
262
+ }
263
+ }
264
+
265
+ // Fill in zeros for scenarios that don't have a given program
266
+ for (const entry of programMap.values()) {
267
+ for (const scenario of loaded) {
268
+ if (!(scenario.name in entry.projectedByScenario)) {
269
+ entry.projectedByScenario[scenario.name] = 0
270
+ }
271
+ }
272
+ }
273
+
274
+ const programs = Array.from(programMap.entries())
275
+ .map(([programCode, entry]) => ({
276
+ programCode,
277
+ masterProgram: entry.masterProgram,
278
+ actual: entry.actual,
279
+ projectedByScenario: entry.projectedByScenario,
280
+ }))
281
+ .sort((a, b) => a.programCode.localeCompare(b.programCode))
282
+
283
+ const totalsByScenario: Record<string, { totalProjected: number; percentageChange: number }> = {}
284
+ for (const scenario of loaded) {
285
+ totalsByScenario[scenario.name] = {
286
+ totalProjected: scenario.totals.totalProjected,
287
+ percentageChange: scenario.totals.percentageChange,
288
+ }
289
+ }
290
+
291
+ return {
292
+ scenarioNames: loaded.map((s) => s.name),
293
+ programs,
294
+ totalsByScenario,
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Delete a scenario file from disk.
300
+ *
301
+ * Throws if the scenario does not exist.
302
+ */
303
+ export async function deleteScenario(name: string): Promise<void> {
304
+ const sanitized = sanitizeName(name)
305
+ const filePath = scenarioPath(sanitized)
306
+
307
+ try {
308
+ unlinkSync(filePath)
309
+ } catch {
310
+ throw new Error(`Scenario not found: "${name}" (looked for ${filePath})`)
311
+ }
312
+ }
@@ -0,0 +1,129 @@
1
+ import 'dotenv/config'
2
+ import { deploy } from '../infra/deploy.js'
3
+ import {
4
+ strapiGet,
5
+ strapiPost,
6
+ findBySlug,
7
+ publish,
8
+ StrapiItem,
9
+ StrapiPage,
10
+ } from './strapi-client.js'
11
+
12
+ // ── Types ─────────────────────────────────────────────────────────────
13
+
14
+ export interface PublishBlogOptions {
15
+ slug: string
16
+ deployAfter?: boolean // default false
17
+ site?: string // 'portfolio' | 'insurance' etc
18
+ }
19
+
20
+ export interface PublishBlogResult {
21
+ documentId: string
22
+ slug: string
23
+ published: boolean
24
+ deployUrl?: string
25
+ }
26
+
27
+ export interface BlogPostData {
28
+ title: string
29
+ slug: string
30
+ content: string
31
+ site: string
32
+ tags?: string[]
33
+ excerpt?: string
34
+ }
35
+
36
+ export interface BlogPostSummary {
37
+ documentId: string
38
+ title: string
39
+ slug: string
40
+ site: string
41
+ createdAt: string
42
+ }
43
+
44
+ // ── Functions ─────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Main orchestrator: find a blog post by slug, publish it in Strapi,
48
+ * and optionally deploy the portfolio site to Vercel.
49
+ *
50
+ * @throws If no blog post is found for the given slug.
51
+ *
52
+ * @example
53
+ * const result = await publishBlog({ slug: 'copper-investment-thesis-2026', deployAfter: true })
54
+ * console.log(result.deployUrl) // https://portfolio-2026.vercel.app
55
+ */
56
+ export async function publishBlog(
57
+ opts: PublishBlogOptions,
58
+ ): Promise<PublishBlogResult> {
59
+ const { slug, deployAfter = false } = opts
60
+
61
+ const item = await findBySlug('blog-posts', slug)
62
+ if (!item) {
63
+ throw new Error(`Blog post not found for slug: "${slug}"`)
64
+ }
65
+
66
+ const { documentId } = item
67
+
68
+ await publish('blog-posts', documentId)
69
+
70
+ let deployUrl: string | undefined
71
+ if (deployAfter) {
72
+ deployUrl = await deploy('portfolio', true)
73
+ }
74
+
75
+ return {
76
+ documentId,
77
+ slug,
78
+ published: true,
79
+ ...(deployUrl !== undefined ? { deployUrl } : {}),
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Create a new blog post draft in Strapi.
85
+ *
86
+ * @example
87
+ * const post = await createBlogPost({
88
+ * title: 'Copper Investment Thesis 2026',
89
+ * slug: 'copper-investment-thesis-2026',
90
+ * content: '## Overview\n...',
91
+ * site: 'portfolio',
92
+ * tags: ['Automated Report'],
93
+ * })
94
+ */
95
+ export async function createBlogPost(data: BlogPostData): Promise<StrapiItem> {
96
+ return strapiPost('/api/blog-posts', data as unknown as Record<string, unknown>)
97
+ }
98
+
99
+ /**
100
+ * List unpublished blog post drafts from Strapi, optionally filtered by site.
101
+ *
102
+ * @param site - Optional site key to filter by (e.g. 'portfolio', 'insurance').
103
+ *
104
+ * @example
105
+ * const drafts = await listBlogDrafts('portfolio')
106
+ * drafts.forEach(d => console.log(d.slug, d.createdAt))
107
+ */
108
+ export async function listBlogDrafts(
109
+ site?: string,
110
+ ): Promise<BlogPostSummary[]> {
111
+ const params: Record<string, string> = {
112
+ status: 'draft',
113
+ 'sort': 'createdAt:desc',
114
+ }
115
+
116
+ if (site) {
117
+ params['filters[site][$eq]'] = site
118
+ }
119
+
120
+ const result = await strapiGet<StrapiPage>('/api/blog-posts', params)
121
+
122
+ return result.data.map((item) => ({
123
+ documentId: item.documentId,
124
+ title: String(item.title ?? ''),
125
+ slug: String(item.slug ?? ''),
126
+ site: String(item.site ?? ''),
127
+ createdAt: String(item.createdAt ?? ''),
128
+ }))
129
+ }