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,302 @@
1
+ import 'dotenv/config'
2
+ import { readFileSync } from 'node:fs'
3
+ import { basename } from 'node:path'
4
+
5
+ // ── Types ────────────────────────────────────────────────────────────
6
+
7
+ export interface StrapiItem {
8
+ id: number
9
+ documentId: string
10
+ [key: string]: unknown
11
+ }
12
+
13
+ export interface StrapiPagination {
14
+ page: number
15
+ pageSize: number
16
+ pageCount: number
17
+ total: number
18
+ }
19
+
20
+ export interface StrapiPage {
21
+ data: StrapiItem[]
22
+ meta: { pagination: StrapiPagination }
23
+ }
24
+
25
+ export interface StrapiError {
26
+ status: number
27
+ name: string
28
+ message: string
29
+ details?: Record<string, unknown>
30
+ }
31
+
32
+ export class StrapiClientError extends Error {
33
+ constructor(
34
+ message: string,
35
+ public status: number,
36
+ public strapiError?: StrapiError,
37
+ ) {
38
+ super(message)
39
+ this.name = 'StrapiClientError'
40
+ }
41
+ }
42
+
43
+ // ── Config ───────────────────────────────────────────────────────────
44
+
45
+ function getConfig() {
46
+ const url = process.env.STRAPI_URL
47
+ const token = process.env.STRAPI_API_TOKEN
48
+ if (!url || !token) {
49
+ throw new Error('Missing env vars: STRAPI_URL, STRAPI_API_TOKEN')
50
+ }
51
+ return { url: url.replace(/\/+$/, ''), token }
52
+ }
53
+
54
+ // ── Internal request helper ──────────────────────────────────────────
55
+
56
+ async function request<T>(
57
+ path: string,
58
+ opts: RequestInit = {},
59
+ ): Promise<T> {
60
+ const { url, token } = getConfig()
61
+ const fullUrl = `${url}${path}`
62
+
63
+ const res = await fetch(fullUrl, {
64
+ ...opts,
65
+ headers: {
66
+ Authorization: `Bearer ${token}`,
67
+ 'Content-Type': 'application/json',
68
+ ...opts.headers,
69
+ },
70
+ })
71
+
72
+ if (!res.ok) {
73
+ let strapiErr: StrapiError | undefined
74
+ try {
75
+ const body = await res.json()
76
+ strapiErr = body?.error
77
+ } catch {
78
+ // non-JSON error body
79
+ }
80
+ throw new StrapiClientError(
81
+ strapiErr?.message ?? `Strapi ${res.status}: ${res.statusText}`,
82
+ res.status,
83
+ strapiErr,
84
+ )
85
+ }
86
+
87
+ if (res.status === 204) return undefined as T
88
+ return res.json() as Promise<T>
89
+ }
90
+
91
+ // ── CRUD Functions ───────────────────────────────────────────────────
92
+
93
+ /**
94
+ * GET a Strapi endpoint with optional query params.
95
+ * Returns the full parsed JSON response.
96
+ *
97
+ * @example
98
+ * const result = await strapiGet('/api/newsletters', { 'status': 'draft' })
99
+ */
100
+ export async function strapiGet<T = StrapiPage>(
101
+ endpoint: string,
102
+ params?: Record<string, string>,
103
+ ): Promise<T> {
104
+ const qs = params ? new URLSearchParams(params).toString() : ''
105
+ const path = qs ? `${endpoint}?${qs}` : endpoint
106
+ return request<T>(path)
107
+ }
108
+
109
+ /**
110
+ * POST to a Strapi endpoint. Wraps data in `{ data }` per Strapi v5 convention.
111
+ * Returns the created item.
112
+ *
113
+ * @example
114
+ * const item = await strapiPost('/api/social-posts', {
115
+ * headline: 'New post',
116
+ * brand: 'LIFEINSUR',
117
+ * })
118
+ */
119
+ export async function strapiPost(
120
+ endpoint: string,
121
+ data: Record<string, unknown>,
122
+ ): Promise<StrapiItem> {
123
+ const result = await request<{ data: StrapiItem }>(endpoint, {
124
+ method: 'POST',
125
+ body: JSON.stringify({ data }),
126
+ })
127
+ return result.data
128
+ }
129
+
130
+ /**
131
+ * PUT to a Strapi endpoint by documentId. Wraps data in `{ data }`.
132
+ *
133
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
134
+ *
135
+ * @example
136
+ * await strapiPut('/api/newsletters', 'abc123-def456', { subject_line: 'Updated' })
137
+ */
138
+ export async function strapiPut(
139
+ endpoint: string,
140
+ documentId: string,
141
+ data: Record<string, unknown>,
142
+ ): Promise<StrapiItem> {
143
+ const result = await request<{ data: StrapiItem }>(`${endpoint}/${documentId}`, {
144
+ method: 'PUT',
145
+ body: JSON.stringify({ data }),
146
+ })
147
+ return result.data
148
+ }
149
+
150
+ /**
151
+ * DELETE a Strapi item by documentId.
152
+ *
153
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
154
+ *
155
+ * @example
156
+ * await strapiDelete('/api/social-posts', 'abc123-def456')
157
+ */
158
+ export async function strapiDelete(
159
+ endpoint: string,
160
+ documentId: string,
161
+ ): Promise<void> {
162
+ await request<void>(`${endpoint}/${documentId}`, { method: 'DELETE' })
163
+ }
164
+
165
+ /**
166
+ * Upload a file to Strapi's `/api/upload` endpoint via multipart form.
167
+ *
168
+ * Optionally link the upload to an existing entry via `refData`:
169
+ * - ref: content type UID (e.g. 'api::newsletter.newsletter')
170
+ * - refId: documentId of the entry to attach to
171
+ * - field: field name on the content type (e.g. 'cover_image')
172
+ *
173
+ * @example
174
+ * const uploaded = await strapiUploadFile('/path/to/image.png')
175
+ * const linked = await strapiUploadFile('/path/to/cover.jpg', {
176
+ * ref: 'api::blog-post.blog-post',
177
+ * refId: 'abc123',
178
+ * field: 'cover',
179
+ * })
180
+ */
181
+ export async function strapiUploadFile(
182
+ filePath: string,
183
+ refData?: { ref: string; refId: string; field: string },
184
+ ): Promise<StrapiItem[]> {
185
+ const { url, token } = getConfig()
186
+
187
+ const fileBuffer = readFileSync(filePath)
188
+ const fileName = basename(filePath)
189
+
190
+ const formData = new FormData()
191
+ formData.append('files', new Blob([fileBuffer]), fileName)
192
+
193
+ if (refData) {
194
+ formData.append('ref', refData.ref)
195
+ formData.append('refId', refData.refId)
196
+ formData.append('field', refData.field)
197
+ }
198
+
199
+ const res = await fetch(`${url}/api/upload`, {
200
+ method: 'POST',
201
+ headers: {
202
+ Authorization: `Bearer ${token}`,
203
+ // Do NOT set Content-Type — fetch sets it with the multipart boundary
204
+ },
205
+ body: formData,
206
+ })
207
+
208
+ if (!res.ok) {
209
+ let strapiErr: StrapiError | undefined
210
+ try {
211
+ const body = await res.json()
212
+ strapiErr = body?.error
213
+ } catch {
214
+ // non-JSON error body
215
+ }
216
+ throw new StrapiClientError(
217
+ strapiErr?.message ?? `Upload failed ${res.status}: ${res.statusText}`,
218
+ res.status,
219
+ strapiErr,
220
+ )
221
+ }
222
+
223
+ return res.json() as Promise<StrapiItem[]>
224
+ }
225
+
226
+ // ── Convenience ──────────────────────────────────────────────────────
227
+
228
+ /**
229
+ * List items of a content type filtered by brand, with optional status filter.
230
+ * This is the most common query pattern in Optimal's multi-brand CMS setup.
231
+ *
232
+ * Content types: 'newsletters', 'social-posts', 'blog-posts'
233
+ * Brands: 'CRE-11TRUST', 'LIFEINSUR'
234
+ * Status: 'draft' or 'published' (Strapi's draftAndPublish)
235
+ *
236
+ * @example
237
+ * const drafts = await listByBrand('social-posts', 'LIFEINSUR', 'draft')
238
+ * const published = await listByBrand('newsletters', 'CRE-11TRUST', 'published')
239
+ * const all = await listByBrand('blog-posts', 'CRE-11TRUST')
240
+ */
241
+ export async function listByBrand(
242
+ contentType: string,
243
+ brand: string,
244
+ status?: 'draft' | 'published',
245
+ ): Promise<StrapiPage> {
246
+ const params: Record<string, string> = {
247
+ 'filters[brand][$eq]': brand,
248
+ 'sort': 'createdAt:desc',
249
+ }
250
+ if (status) {
251
+ params['status'] = status
252
+ }
253
+ return strapiGet<StrapiPage>(`/api/${contentType}`, params)
254
+ }
255
+
256
+ /**
257
+ * Find a single item by slug within a content type.
258
+ * Returns null if not found.
259
+ *
260
+ * @example
261
+ * const post = await findBySlug('blog-posts', 'copper-investment-thesis-2026')
262
+ */
263
+ export async function findBySlug(
264
+ contentType: string,
265
+ slug: string,
266
+ ): Promise<StrapiItem | null> {
267
+ const result = await strapiGet<StrapiPage>(`/api/${contentType}`, {
268
+ 'filters[slug][$eq]': slug,
269
+ 'pagination[pageSize]': '1',
270
+ })
271
+ return result.data[0] ?? null
272
+ }
273
+
274
+ /**
275
+ * Publish an item by setting publishedAt via PUT.
276
+ *
277
+ * @example
278
+ * await publish('newsletters', 'abc123-def456')
279
+ */
280
+ export async function publish(
281
+ contentType: string,
282
+ documentId: string,
283
+ ): Promise<StrapiItem> {
284
+ return strapiPut(`/api/${contentType}`, documentId, {
285
+ publishedAt: new Date().toISOString(),
286
+ })
287
+ }
288
+
289
+ /**
290
+ * Unpublish (revert to draft) by clearing publishedAt.
291
+ *
292
+ * @example
293
+ * await unpublish('newsletters', 'abc123-def456')
294
+ */
295
+ export async function unpublish(
296
+ contentType: string,
297
+ documentId: string,
298
+ ): Promise<StrapiItem> {
299
+ return strapiPut(`/api/${contentType}`, documentId, {
300
+ publishedAt: null,
301
+ })
302
+ }
@@ -0,0 +1,229 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { homedir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { createHash } from 'node:crypto'
6
+ import { getSupabase } from '../supabase.js'
7
+ import { assertOptimalConfigV1, type OptimalConfigV1 } from './schema.js'
8
+
9
+ // Support optional config directory override via env var (useful for testing)
10
+ const CONFIG_DIR = process.env.OPTIMAL_CONFIG_DIR || join(homedir(), '.optimal')
11
+ const LOCAL_CONFIG_PATH = join(CONFIG_DIR, 'optimal.config.json')
12
+ const HISTORY_PATH = join(CONFIG_DIR, 'config-history.log')
13
+ const REGISTRY_TABLE = 'cli_config_registry'
14
+
15
+ let supabaseProvider: typeof getSupabase = getSupabase
16
+
17
+ function getGlobalProviderOverride(): typeof getSupabase | null {
18
+ const candidate = (globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider
19
+ return candidate ?? null
20
+ }
21
+
22
+ function getActiveSupabaseProvider(): typeof getSupabase {
23
+ return getGlobalProviderOverride() ?? supabaseProvider
24
+ }
25
+
26
+ export function setRegistrySupabaseProviderForTests(provider: typeof getSupabase): void {
27
+ supabaseProvider = provider
28
+ ;(globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider = provider
29
+ }
30
+
31
+ export function resetRegistrySupabaseProviderForTests(): void {
32
+ supabaseProvider = getSupabase
33
+ delete (globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider
34
+ }
35
+
36
+ export async function ensureConfigDir(): Promise<void> {
37
+ await mkdir(CONFIG_DIR, { recursive: true })
38
+ }
39
+
40
+ export function getLocalConfigPath(): string {
41
+ return LOCAL_CONFIG_PATH
42
+ }
43
+
44
+ export function getHistoryPath(): string {
45
+ return HISTORY_PATH
46
+ }
47
+
48
+ export async function readLocalConfig(): Promise<OptimalConfigV1 | null> {
49
+ if (!existsSync(LOCAL_CONFIG_PATH)) return null
50
+ const raw = await readFile(LOCAL_CONFIG_PATH, 'utf-8')
51
+ const parsed = JSON.parse(raw)
52
+ return assertOptimalConfigV1(parsed)
53
+ }
54
+
55
+ export async function writeLocalConfig(config: OptimalConfigV1): Promise<void> {
56
+ await ensureConfigDir()
57
+ await writeFile(LOCAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
58
+ }
59
+
60
+ export function hashConfig(config: OptimalConfigV1): string {
61
+ const payload = JSON.stringify(config)
62
+ return createHash('sha256').update(payload).digest('hex')
63
+ }
64
+
65
+ export async function appendHistory(entry: string): Promise<void> {
66
+ await ensureConfigDir()
67
+ await writeFile(HISTORY_PATH, `${entry}\n`, { encoding: 'utf-8', flag: 'a' })
68
+ }
69
+
70
+ export type RegistrySyncResult = {
71
+ ok: boolean
72
+ message: string
73
+ }
74
+
75
+ type RegistryRow = {
76
+ owner: string
77
+ profile: string
78
+ config_version: string
79
+ payload: unknown
80
+ payload_hash: string
81
+ updated_at: string
82
+ }
83
+
84
+ function resolveOwner(local: OptimalConfigV1 | null): string | null {
85
+ return local?.profile.owner || process.env.OPTIMAL_CONFIG_OWNER || null
86
+ }
87
+
88
+ function parseEpoch(input: string | undefined): number {
89
+ if (!input) return 0
90
+ const ts = Date.parse(input)
91
+ return Number.isNaN(ts) ? 0 : ts
92
+ }
93
+
94
+ export async function pullRegistryProfile(profile = 'default'): Promise<RegistrySyncResult> {
95
+ try {
96
+ const local = await readLocalConfig()
97
+ const owner = resolveOwner(local)
98
+ if (!owner) {
99
+ return {
100
+ ok: false,
101
+ message: 'registry pull failed: missing owner (set local config profile.owner or OPTIMAL_CONFIG_OWNER)',
102
+ }
103
+ }
104
+
105
+ const supabase = getActiveSupabaseProvider()('optimal')
106
+ const { data, error } = await supabase
107
+ .from(REGISTRY_TABLE)
108
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
109
+ .eq('owner', owner)
110
+ .eq('profile', profile)
111
+ .maybeSingle()
112
+
113
+ if (error) {
114
+ return { ok: false, message: `registry pull failed: ${error.message}` }
115
+ }
116
+ if (!data) {
117
+ return { ok: false, message: `registry pull failed: no remote profile found for owner=${owner} profile=${profile}` }
118
+ }
119
+
120
+ const row = data as RegistryRow
121
+ const payload = assertOptimalConfigV1(row.payload)
122
+ await writeLocalConfig(payload)
123
+
124
+ const localHash = local ? hashConfig(local) : null
125
+ const changed = localHash !== row.payload_hash
126
+
127
+ return {
128
+ ok: true,
129
+ message: changed
130
+ ? `registry pull ok: wrote owner=${owner} profile=${profile} hash=${row.payload_hash.slice(0, 12)}`
131
+ : `registry pull ok: local already matched owner=${owner} profile=${profile}`,
132
+ }
133
+ } catch (err) {
134
+ return {
135
+ ok: false,
136
+ message: `registry pull failed: ${err instanceof Error ? err.message : String(err)}`,
137
+ }
138
+ }
139
+ }
140
+
141
+ export async function pushRegistryProfile(profile = 'default', force = false, agent?: string): Promise<RegistrySyncResult> {
142
+ try {
143
+ const local = await readLocalConfig()
144
+ if (!local) {
145
+ return { ok: false, message: `registry push failed: no local config at ${LOCAL_CONFIG_PATH}` }
146
+ }
147
+
148
+ let owner = resolveOwner(local)
149
+ if (!owner && agent) {
150
+ owner = agent
151
+ local.profile.owner = owner
152
+ }
153
+ if (!owner) {
154
+ return {
155
+ ok: false,
156
+ message: 'registry push failed: missing owner (set local config profile.owner, OPTIMAL_CONFIG_OWNER, or use --agent)',
157
+ }
158
+ }
159
+
160
+ const localHash = hashConfig(local)
161
+ const supabase = getActiveSupabaseProvider()('optimal')
162
+
163
+ const { data: existing, error: readErr } = await supabase
164
+ .from(REGISTRY_TABLE)
165
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
166
+ .eq('owner', owner)
167
+ .eq('profile', profile)
168
+ .maybeSingle()
169
+
170
+ if (readErr) {
171
+ return { ok: false, message: `registry push failed: ${readErr.message}` }
172
+ }
173
+
174
+ if (existing) {
175
+ const row = existing as RegistryRow
176
+ if (row.payload_hash !== localHash && !force) {
177
+ const remotePayload = assertOptimalConfigV1(row.payload)
178
+ const remoteTs = Math.max(parseEpoch(row.updated_at), parseEpoch(remotePayload.profile.updated_at))
179
+ const localTs = parseEpoch(local.profile.updated_at)
180
+
181
+ if (remoteTs >= localTs) {
182
+ return {
183
+ ok: false,
184
+ message:
185
+ `registry push conflict: remote is newer/different for owner=${owner} profile=${profile}. ` +
186
+ 'run `optimal config sync pull` or retry with --force',
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ const payload: OptimalConfigV1 = {
193
+ ...local,
194
+ profile: {
195
+ ...local.profile,
196
+ name: profile,
197
+ owner,
198
+ updated_at: new Date().toISOString(),
199
+ },
200
+ }
201
+
202
+ const { error: upsertErr } = await supabase.from(REGISTRY_TABLE).upsert(
203
+ {
204
+ owner,
205
+ profile,
206
+ config_version: payload.version,
207
+ payload,
208
+ payload_hash: hashConfig(payload),
209
+ source: 'optimal-cli',
210
+ updated_by: process.env.USER || 'oracle',
211
+ },
212
+ { onConflict: 'owner,profile' },
213
+ )
214
+
215
+ if (upsertErr) {
216
+ return { ok: false, message: `registry push failed: ${upsertErr.message}` }
217
+ }
218
+
219
+ return {
220
+ ok: true,
221
+ message: `registry push ok: owner=${owner} profile=${profile} hash=${hashConfig(payload).slice(0, 12)} force=${force}`,
222
+ }
223
+ } catch (err) {
224
+ return {
225
+ ok: false,
226
+ message: `registry push failed: ${err instanceof Error ? err.message : String(err)}`,
227
+ }
228
+ }
229
+ }
@@ -0,0 +1,58 @@
1
+ export type ConfigSchemaVersion = '1.0.0'
2
+
3
+ export interface OptimalConfigV1 {
4
+ version: ConfigSchemaVersion
5
+ profile: {
6
+ name: string
7
+ owner: string
8
+ updated_at: string
9
+ }
10
+ providers: {
11
+ supabase: {
12
+ project_ref: string
13
+ url: string
14
+ anon_key_present: boolean
15
+ }
16
+ strapi: {
17
+ base_url: string
18
+ token_present: boolean
19
+ }
20
+ }
21
+ defaults: {
22
+ brand: string
23
+ timezone: string
24
+ }
25
+ features: {
26
+ cms: boolean
27
+ tasks: boolean
28
+ deploy: boolean
29
+ }
30
+ }
31
+
32
+ export function isOptimalConfigV1(value: unknown): value is OptimalConfigV1 {
33
+ if (!value || typeof value !== 'object') return false
34
+ const v = value as Record<string, any>
35
+ return (
36
+ v.version === '1.0.0' &&
37
+ typeof v.profile?.name === 'string' &&
38
+ typeof v.profile?.owner === 'string' &&
39
+ typeof v.profile?.updated_at === 'string' &&
40
+ typeof v.providers?.supabase?.project_ref === 'string' &&
41
+ typeof v.providers?.supabase?.url === 'string' &&
42
+ typeof v.providers?.supabase?.anon_key_present === 'boolean' &&
43
+ typeof v.providers?.strapi?.base_url === 'string' &&
44
+ typeof v.providers?.strapi?.token_present === 'boolean' &&
45
+ typeof v.defaults?.brand === 'string' &&
46
+ typeof v.defaults?.timezone === 'string' &&
47
+ typeof v.features?.cms === 'boolean' &&
48
+ typeof v.features?.tasks === 'boolean' &&
49
+ typeof v.features?.deploy === 'boolean'
50
+ )
51
+ }
52
+
53
+ export function assertOptimalConfigV1(value: unknown): OptimalConfigV1 {
54
+ if (!isOptimalConfigV1(value)) {
55
+ throw new Error('Invalid optimal config payload (v1)')
56
+ }
57
+ return value
58
+ }