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.
- package/agents/.gitkeep +0 -0
- package/agents/content-ops.md +227 -0
- package/agents/financial-ops.md +184 -0
- package/agents/infra-ops.md +206 -0
- package/agents/profiles.json +5 -0
- package/bin/optimal.ts +1731 -0
- package/docs/CLI-REFERENCE.md +361 -0
- package/lib/assets/index.ts +225 -0
- package/lib/assets.ts +124 -0
- package/lib/auth/index.ts +189 -0
- package/lib/board/index.ts +309 -0
- package/lib/board/types.ts +124 -0
- package/lib/bot/claim.ts +43 -0
- package/lib/bot/coordinator.ts +254 -0
- package/lib/bot/heartbeat.ts +37 -0
- package/lib/bot/index.ts +9 -0
- package/lib/bot/protocol.ts +99 -0
- package/lib/bot/reporter.ts +42 -0
- package/lib/bot/skills.ts +81 -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 +228 -0
- package/lib/config/schema.ts +58 -0
- package/lib/config.ts +247 -0
- package/lib/errors.ts +129 -0
- package/lib/format.ts +120 -0
- package/lib/infra/.gitkeep +0 -0
- package/lib/infra/deploy.ts +70 -0
- package/lib/infra/migrate.ts +141 -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/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/returnpro/validate.ts +154 -0
- package/lib/social/meta.ts +228 -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 +15 -25
- package/dist/bin/optimal.d.ts +0 -2
- package/dist/bin/optimal.js +0 -995
- 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.d.ts +0 -55
- package/dist/lib/config.js +0 -206
- 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/kanban.d.ts +0 -46
- package/dist/lib/kanban.js +0 -118
- 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/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
|
@@ -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
|
+
}
|