optimal-cli 0.1.0 → 1.0.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 (107) hide show
  1. package/agents/.gitkeep +0 -0
  2. package/agents/content-ops.md +227 -0
  3. package/agents/financial-ops.md +184 -0
  4. package/agents/infra-ops.md +206 -0
  5. package/agents/profiles.json +5 -0
  6. package/bin/optimal.ts +1731 -0
  7. package/docs/CLI-REFERENCE.md +361 -0
  8. package/lib/assets/index.ts +225 -0
  9. package/lib/assets.ts +124 -0
  10. package/lib/auth/index.ts +189 -0
  11. package/lib/board/index.ts +309 -0
  12. package/lib/board/types.ts +124 -0
  13. package/lib/bot/claim.ts +43 -0
  14. package/lib/bot/coordinator.ts +254 -0
  15. package/lib/bot/heartbeat.ts +37 -0
  16. package/lib/bot/index.ts +9 -0
  17. package/lib/bot/protocol.ts +99 -0
  18. package/lib/bot/reporter.ts +42 -0
  19. package/lib/bot/skills.ts +81 -0
  20. package/lib/budget/projections.ts +561 -0
  21. package/lib/budget/scenarios.ts +312 -0
  22. package/lib/cms/publish-blog.ts +129 -0
  23. package/lib/cms/strapi-client.ts +302 -0
  24. package/lib/config/registry.ts +228 -0
  25. package/lib/config/schema.ts +58 -0
  26. package/lib/config.ts +247 -0
  27. package/lib/errors.ts +129 -0
  28. package/lib/format.ts +120 -0
  29. package/lib/infra/.gitkeep +0 -0
  30. package/lib/infra/deploy.ts +70 -0
  31. package/lib/infra/migrate.ts +141 -0
  32. package/lib/newsletter/.gitkeep +0 -0
  33. package/lib/newsletter/distribute.ts +256 -0
  34. package/{dist/lib/newsletter/generate-insurance.d.ts → lib/newsletter/generate-insurance.ts} +24 -7
  35. package/lib/newsletter/generate.ts +735 -0
  36. package/lib/returnpro/.gitkeep +0 -0
  37. package/lib/returnpro/anomalies.ts +258 -0
  38. package/lib/returnpro/audit.ts +194 -0
  39. package/lib/returnpro/diagnose.ts +400 -0
  40. package/lib/returnpro/kpis.ts +255 -0
  41. package/lib/returnpro/templates.ts +323 -0
  42. package/lib/returnpro/upload-income.ts +311 -0
  43. package/lib/returnpro/upload-netsuite.ts +696 -0
  44. package/lib/returnpro/upload-r1.ts +563 -0
  45. package/lib/returnpro/validate.ts +154 -0
  46. package/lib/social/meta.ts +228 -0
  47. package/lib/social/post-generator.ts +468 -0
  48. package/lib/social/publish.ts +301 -0
  49. package/lib/social/scraper.ts +503 -0
  50. package/lib/supabase.ts +25 -0
  51. package/lib/transactions/delete-batch.ts +258 -0
  52. package/lib/transactions/ingest.ts +659 -0
  53. package/lib/transactions/stamp.ts +654 -0
  54. package/package.json +15 -25
  55. package/dist/bin/optimal.d.ts +0 -2
  56. package/dist/bin/optimal.js +0 -995
  57. package/dist/lib/budget/projections.d.ts +0 -115
  58. package/dist/lib/budget/projections.js +0 -384
  59. package/dist/lib/budget/scenarios.d.ts +0 -93
  60. package/dist/lib/budget/scenarios.js +0 -214
  61. package/dist/lib/cms/publish-blog.d.ts +0 -62
  62. package/dist/lib/cms/publish-blog.js +0 -74
  63. package/dist/lib/cms/strapi-client.d.ts +0 -123
  64. package/dist/lib/cms/strapi-client.js +0 -213
  65. package/dist/lib/config.d.ts +0 -55
  66. package/dist/lib/config.js +0 -206
  67. package/dist/lib/infra/deploy.d.ts +0 -29
  68. package/dist/lib/infra/deploy.js +0 -58
  69. package/dist/lib/infra/migrate.d.ts +0 -34
  70. package/dist/lib/infra/migrate.js +0 -103
  71. package/dist/lib/kanban.d.ts +0 -46
  72. package/dist/lib/kanban.js +0 -118
  73. package/dist/lib/newsletter/distribute.d.ts +0 -52
  74. package/dist/lib/newsletter/distribute.js +0 -193
  75. package/dist/lib/newsletter/generate-insurance.js +0 -36
  76. package/dist/lib/newsletter/generate.d.ts +0 -104
  77. package/dist/lib/newsletter/generate.js +0 -571
  78. package/dist/lib/returnpro/anomalies.d.ts +0 -64
  79. package/dist/lib/returnpro/anomalies.js +0 -166
  80. package/dist/lib/returnpro/audit.d.ts +0 -32
  81. package/dist/lib/returnpro/audit.js +0 -147
  82. package/dist/lib/returnpro/diagnose.d.ts +0 -52
  83. package/dist/lib/returnpro/diagnose.js +0 -281
  84. package/dist/lib/returnpro/kpis.d.ts +0 -32
  85. package/dist/lib/returnpro/kpis.js +0 -192
  86. package/dist/lib/returnpro/templates.d.ts +0 -48
  87. package/dist/lib/returnpro/templates.js +0 -229
  88. package/dist/lib/returnpro/upload-income.d.ts +0 -25
  89. package/dist/lib/returnpro/upload-income.js +0 -235
  90. package/dist/lib/returnpro/upload-netsuite.d.ts +0 -37
  91. package/dist/lib/returnpro/upload-netsuite.js +0 -566
  92. package/dist/lib/returnpro/upload-r1.d.ts +0 -48
  93. package/dist/lib/returnpro/upload-r1.js +0 -398
  94. package/dist/lib/social/post-generator.d.ts +0 -83
  95. package/dist/lib/social/post-generator.js +0 -333
  96. package/dist/lib/social/publish.d.ts +0 -66
  97. package/dist/lib/social/publish.js +0 -226
  98. package/dist/lib/social/scraper.d.ts +0 -67
  99. package/dist/lib/social/scraper.js +0 -361
  100. package/dist/lib/supabase.d.ts +0 -4
  101. package/dist/lib/supabase.js +0 -20
  102. package/dist/lib/transactions/delete-batch.d.ts +0 -60
  103. package/dist/lib/transactions/delete-batch.js +0 -203
  104. package/dist/lib/transactions/ingest.d.ts +0 -43
  105. package/dist/lib/transactions/ingest.js +0 -555
  106. package/dist/lib/transactions/stamp.d.ts +0 -51
  107. package/dist/lib/transactions/stamp.js +0 -524
@@ -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,228 @@
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
+ const DIR = join(homedir(), '.optimal')
10
+ const LOCAL_CONFIG_PATH = join(DIR, 'optimal.config.json')
11
+ const HISTORY_PATH = join(DIR, 'config-history.log')
12
+ const REGISTRY_TABLE = 'cli_config_registry'
13
+
14
+ let supabaseProvider: typeof getSupabase = getSupabase
15
+
16
+ function getGlobalProviderOverride(): typeof getSupabase | null {
17
+ const candidate = (globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider
18
+ return candidate ?? null
19
+ }
20
+
21
+ function getActiveSupabaseProvider(): typeof getSupabase {
22
+ return getGlobalProviderOverride() ?? supabaseProvider
23
+ }
24
+
25
+ export function setRegistrySupabaseProviderForTests(provider: typeof getSupabase): void {
26
+ supabaseProvider = provider
27
+ ;(globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider = provider
28
+ }
29
+
30
+ export function resetRegistrySupabaseProviderForTests(): void {
31
+ supabaseProvider = getSupabase
32
+ delete (globalThis as { __optimalRegistrySupabaseProvider?: typeof getSupabase }).__optimalRegistrySupabaseProvider
33
+ }
34
+
35
+ export async function ensureConfigDir(): Promise<void> {
36
+ await mkdir(DIR, { recursive: true })
37
+ }
38
+
39
+ export function getLocalConfigPath(): string {
40
+ return LOCAL_CONFIG_PATH
41
+ }
42
+
43
+ export function getHistoryPath(): string {
44
+ return HISTORY_PATH
45
+ }
46
+
47
+ export async function readLocalConfig(): Promise<OptimalConfigV1 | null> {
48
+ if (!existsSync(LOCAL_CONFIG_PATH)) return null
49
+ const raw = await readFile(LOCAL_CONFIG_PATH, 'utf-8')
50
+ const parsed = JSON.parse(raw)
51
+ return assertOptimalConfigV1(parsed)
52
+ }
53
+
54
+ export async function writeLocalConfig(config: OptimalConfigV1): Promise<void> {
55
+ await ensureConfigDir()
56
+ await writeFile(LOCAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf-8')
57
+ }
58
+
59
+ export function hashConfig(config: OptimalConfigV1): string {
60
+ const payload = JSON.stringify(config)
61
+ return createHash('sha256').update(payload).digest('hex')
62
+ }
63
+
64
+ export async function appendHistory(entry: string): Promise<void> {
65
+ await ensureConfigDir()
66
+ await writeFile(HISTORY_PATH, `${entry}\n`, { encoding: 'utf-8', flag: 'a' })
67
+ }
68
+
69
+ export type RegistrySyncResult = {
70
+ ok: boolean
71
+ message: string
72
+ }
73
+
74
+ type RegistryRow = {
75
+ owner: string
76
+ profile: string
77
+ config_version: string
78
+ payload: unknown
79
+ payload_hash: string
80
+ updated_at: string
81
+ }
82
+
83
+ function resolveOwner(local: OptimalConfigV1 | null): string | null {
84
+ return local?.profile.owner || process.env.OPTIMAL_CONFIG_OWNER || null
85
+ }
86
+
87
+ function parseEpoch(input: string | undefined): number {
88
+ if (!input) return 0
89
+ const ts = Date.parse(input)
90
+ return Number.isNaN(ts) ? 0 : ts
91
+ }
92
+
93
+ export async function pullRegistryProfile(profile = 'default'): Promise<RegistrySyncResult> {
94
+ try {
95
+ const local = await readLocalConfig()
96
+ const owner = resolveOwner(local)
97
+ if (!owner) {
98
+ return {
99
+ ok: false,
100
+ message: 'registry pull failed: missing owner (set local config profile.owner or OPTIMAL_CONFIG_OWNER)',
101
+ }
102
+ }
103
+
104
+ const supabase = getActiveSupabaseProvider()('optimal')
105
+ const { data, error } = await supabase
106
+ .from(REGISTRY_TABLE)
107
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
108
+ .eq('owner', owner)
109
+ .eq('profile', profile)
110
+ .maybeSingle()
111
+
112
+ if (error) {
113
+ return { ok: false, message: `registry pull failed: ${error.message}` }
114
+ }
115
+ if (!data) {
116
+ return { ok: false, message: `registry pull failed: no remote profile found for owner=${owner} profile=${profile}` }
117
+ }
118
+
119
+ const row = data as RegistryRow
120
+ const payload = assertOptimalConfigV1(row.payload)
121
+ await writeLocalConfig(payload)
122
+
123
+ const localHash = local ? hashConfig(local) : null
124
+ const changed = localHash !== row.payload_hash
125
+
126
+ return {
127
+ ok: true,
128
+ message: changed
129
+ ? `registry pull ok: wrote owner=${owner} profile=${profile} hash=${row.payload_hash.slice(0, 12)}`
130
+ : `registry pull ok: local already matched owner=${owner} profile=${profile}`,
131
+ }
132
+ } catch (err) {
133
+ return {
134
+ ok: false,
135
+ message: `registry pull failed: ${err instanceof Error ? err.message : String(err)}`,
136
+ }
137
+ }
138
+ }
139
+
140
+ export async function pushRegistryProfile(profile = 'default', force = false, agent?: string): Promise<RegistrySyncResult> {
141
+ try {
142
+ const local = await readLocalConfig()
143
+ if (!local) {
144
+ return { ok: false, message: `registry push failed: no local config at ${LOCAL_CONFIG_PATH}` }
145
+ }
146
+
147
+ let owner = resolveOwner(local)
148
+ if (!owner && agent) {
149
+ owner = agent
150
+ local.profile.owner = owner
151
+ }
152
+ if (!owner) {
153
+ return {
154
+ ok: false,
155
+ message: 'registry push failed: missing owner (set local config profile.owner, OPTIMAL_CONFIG_OWNER, or use --agent)',
156
+ }
157
+ }
158
+
159
+ const localHash = hashConfig(local)
160
+ const supabase = getActiveSupabaseProvider()('optimal')
161
+
162
+ const { data: existing, error: readErr } = await supabase
163
+ .from(REGISTRY_TABLE)
164
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
165
+ .eq('owner', owner)
166
+ .eq('profile', profile)
167
+ .maybeSingle()
168
+
169
+ if (readErr) {
170
+ return { ok: false, message: `registry push failed: ${readErr.message}` }
171
+ }
172
+
173
+ if (existing) {
174
+ const row = existing as RegistryRow
175
+ if (row.payload_hash !== localHash && !force) {
176
+ const remotePayload = assertOptimalConfigV1(row.payload)
177
+ const remoteTs = Math.max(parseEpoch(row.updated_at), parseEpoch(remotePayload.profile.updated_at))
178
+ const localTs = parseEpoch(local.profile.updated_at)
179
+
180
+ if (remoteTs >= localTs) {
181
+ return {
182
+ ok: false,
183
+ message:
184
+ `registry push conflict: remote is newer/different for owner=${owner} profile=${profile}. ` +
185
+ 'run `optimal config sync pull` or retry with --force',
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ const payload: OptimalConfigV1 = {
192
+ ...local,
193
+ profile: {
194
+ ...local.profile,
195
+ name: profile,
196
+ owner,
197
+ updated_at: new Date().toISOString(),
198
+ },
199
+ }
200
+
201
+ const { error: upsertErr } = await supabase.from(REGISTRY_TABLE).upsert(
202
+ {
203
+ owner,
204
+ profile,
205
+ config_version: payload.version,
206
+ payload,
207
+ payload_hash: hashConfig(payload),
208
+ source: 'optimal-cli',
209
+ updated_by: process.env.USER || 'oracle',
210
+ },
211
+ { onConflict: 'owner,profile' },
212
+ )
213
+
214
+ if (upsertErr) {
215
+ return { ok: false, message: `registry push failed: ${upsertErr.message}` }
216
+ }
217
+
218
+ return {
219
+ ok: true,
220
+ message: `registry push ok: owner=${owner} profile=${profile} hash=${hashConfig(payload).slice(0, 12)} force=${force}`,
221
+ }
222
+ } catch (err) {
223
+ return {
224
+ ok: false,
225
+ message: `registry push failed: ${err instanceof Error ? err.message : String(err)}`,
226
+ }
227
+ }
228
+ }
@@ -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
+ }