optimal-cli 1.0.0 → 1.0.1

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 (135) hide show
  1. package/dist/bin/optimal.d.ts +2 -0
  2. package/dist/bin/optimal.js +1590 -0
  3. package/dist/lib/assets/index.d.ts +79 -0
  4. package/dist/lib/assets/index.js +153 -0
  5. package/dist/lib/assets.d.ts +20 -0
  6. package/dist/lib/assets.js +112 -0
  7. package/dist/lib/auth/index.d.ts +83 -0
  8. package/dist/lib/auth/index.js +146 -0
  9. package/dist/lib/board/index.d.ts +39 -0
  10. package/dist/lib/board/index.js +285 -0
  11. package/dist/lib/board/types.d.ts +111 -0
  12. package/dist/lib/board/types.js +1 -0
  13. package/dist/lib/bot/claim.d.ts +3 -0
  14. package/dist/lib/bot/claim.js +20 -0
  15. package/dist/lib/bot/coordinator.d.ts +27 -0
  16. package/dist/lib/bot/coordinator.js +178 -0
  17. package/dist/lib/bot/heartbeat.d.ts +6 -0
  18. package/dist/lib/bot/heartbeat.js +30 -0
  19. package/dist/lib/bot/index.d.ts +9 -0
  20. package/dist/lib/bot/index.js +6 -0
  21. package/dist/lib/bot/protocol.d.ts +12 -0
  22. package/dist/lib/bot/protocol.js +74 -0
  23. package/dist/lib/bot/reporter.d.ts +3 -0
  24. package/dist/lib/bot/reporter.js +27 -0
  25. package/dist/lib/bot/skills.d.ts +26 -0
  26. package/dist/lib/bot/skills.js +69 -0
  27. package/dist/lib/budget/projections.d.ts +115 -0
  28. package/dist/lib/budget/projections.js +384 -0
  29. package/dist/lib/budget/scenarios.d.ts +93 -0
  30. package/dist/lib/budget/scenarios.js +214 -0
  31. package/dist/lib/cms/publish-blog.d.ts +62 -0
  32. package/dist/lib/cms/publish-blog.js +74 -0
  33. package/dist/lib/cms/strapi-client.d.ts +123 -0
  34. package/dist/lib/cms/strapi-client.js +213 -0
  35. package/dist/lib/config/registry.d.ts +17 -0
  36. package/dist/lib/config/registry.js +182 -0
  37. package/dist/lib/config/schema.d.ts +31 -0
  38. package/dist/lib/config/schema.js +25 -0
  39. package/dist/lib/config.d.ts +55 -0
  40. package/dist/lib/config.js +206 -0
  41. package/dist/lib/errors.d.ts +25 -0
  42. package/dist/lib/errors.js +91 -0
  43. package/dist/lib/format.d.ts +28 -0
  44. package/dist/lib/format.js +98 -0
  45. package/dist/lib/infra/deploy.d.ts +29 -0
  46. package/dist/lib/infra/deploy.js +58 -0
  47. package/dist/lib/infra/migrate.d.ts +34 -0
  48. package/dist/lib/infra/migrate.js +103 -0
  49. package/dist/lib/newsletter/distribute.d.ts +52 -0
  50. package/dist/lib/newsletter/distribute.js +193 -0
  51. package/{lib/newsletter/generate-insurance.ts → dist/lib/newsletter/generate-insurance.d.ts} +7 -24
  52. package/dist/lib/newsletter/generate-insurance.js +36 -0
  53. package/dist/lib/newsletter/generate.d.ts +104 -0
  54. package/dist/lib/newsletter/generate.js +571 -0
  55. package/dist/lib/returnpro/anomalies.d.ts +64 -0
  56. package/dist/lib/returnpro/anomalies.js +166 -0
  57. package/dist/lib/returnpro/audit.d.ts +32 -0
  58. package/dist/lib/returnpro/audit.js +147 -0
  59. package/dist/lib/returnpro/diagnose.d.ts +52 -0
  60. package/dist/lib/returnpro/diagnose.js +281 -0
  61. package/dist/lib/returnpro/kpis.d.ts +32 -0
  62. package/dist/lib/returnpro/kpis.js +192 -0
  63. package/dist/lib/returnpro/templates.d.ts +48 -0
  64. package/dist/lib/returnpro/templates.js +229 -0
  65. package/dist/lib/returnpro/upload-income.d.ts +25 -0
  66. package/dist/lib/returnpro/upload-income.js +235 -0
  67. package/dist/lib/returnpro/upload-netsuite.d.ts +37 -0
  68. package/dist/lib/returnpro/upload-netsuite.js +566 -0
  69. package/dist/lib/returnpro/upload-r1.d.ts +48 -0
  70. package/dist/lib/returnpro/upload-r1.js +398 -0
  71. package/dist/lib/returnpro/validate.d.ts +37 -0
  72. package/dist/lib/returnpro/validate.js +124 -0
  73. package/dist/lib/social/meta.d.ts +90 -0
  74. package/dist/lib/social/meta.js +160 -0
  75. package/dist/lib/social/post-generator.d.ts +83 -0
  76. package/dist/lib/social/post-generator.js +333 -0
  77. package/dist/lib/social/publish.d.ts +66 -0
  78. package/dist/lib/social/publish.js +226 -0
  79. package/dist/lib/social/scraper.d.ts +67 -0
  80. package/dist/lib/social/scraper.js +361 -0
  81. package/dist/lib/supabase.d.ts +4 -0
  82. package/dist/lib/supabase.js +20 -0
  83. package/dist/lib/transactions/delete-batch.d.ts +60 -0
  84. package/dist/lib/transactions/delete-batch.js +203 -0
  85. package/dist/lib/transactions/ingest.d.ts +43 -0
  86. package/dist/lib/transactions/ingest.js +555 -0
  87. package/dist/lib/transactions/stamp.d.ts +51 -0
  88. package/dist/lib/transactions/stamp.js +524 -0
  89. package/package.json +3 -4
  90. package/bin/optimal.ts +0 -1731
  91. package/lib/assets/index.ts +0 -225
  92. package/lib/assets.ts +0 -124
  93. package/lib/auth/index.ts +0 -189
  94. package/lib/board/index.ts +0 -309
  95. package/lib/board/types.ts +0 -124
  96. package/lib/bot/claim.ts +0 -43
  97. package/lib/bot/coordinator.ts +0 -254
  98. package/lib/bot/heartbeat.ts +0 -37
  99. package/lib/bot/index.ts +0 -9
  100. package/lib/bot/protocol.ts +0 -99
  101. package/lib/bot/reporter.ts +0 -42
  102. package/lib/bot/skills.ts +0 -81
  103. package/lib/budget/projections.ts +0 -561
  104. package/lib/budget/scenarios.ts +0 -312
  105. package/lib/cms/publish-blog.ts +0 -129
  106. package/lib/cms/strapi-client.ts +0 -302
  107. package/lib/config/registry.ts +0 -228
  108. package/lib/config/schema.ts +0 -58
  109. package/lib/config.ts +0 -247
  110. package/lib/errors.ts +0 -129
  111. package/lib/format.ts +0 -120
  112. package/lib/infra/.gitkeep +0 -0
  113. package/lib/infra/deploy.ts +0 -70
  114. package/lib/infra/migrate.ts +0 -141
  115. package/lib/newsletter/.gitkeep +0 -0
  116. package/lib/newsletter/distribute.ts +0 -256
  117. package/lib/newsletter/generate.ts +0 -735
  118. package/lib/returnpro/.gitkeep +0 -0
  119. package/lib/returnpro/anomalies.ts +0 -258
  120. package/lib/returnpro/audit.ts +0 -194
  121. package/lib/returnpro/diagnose.ts +0 -400
  122. package/lib/returnpro/kpis.ts +0 -255
  123. package/lib/returnpro/templates.ts +0 -323
  124. package/lib/returnpro/upload-income.ts +0 -311
  125. package/lib/returnpro/upload-netsuite.ts +0 -696
  126. package/lib/returnpro/upload-r1.ts +0 -563
  127. package/lib/returnpro/validate.ts +0 -154
  128. package/lib/social/meta.ts +0 -228
  129. package/lib/social/post-generator.ts +0 -468
  130. package/lib/social/publish.ts +0 -301
  131. package/lib/social/scraper.ts +0 -503
  132. package/lib/supabase.ts +0 -25
  133. package/lib/transactions/delete-batch.ts +0 -258
  134. package/lib/transactions/ingest.ts +0 -659
  135. package/lib/transactions/stamp.ts +0 -654
@@ -0,0 +1,62 @@
1
+ import 'dotenv/config';
2
+ import { StrapiItem } from './strapi-client.js';
3
+ export interface PublishBlogOptions {
4
+ slug: string;
5
+ deployAfter?: boolean;
6
+ site?: string;
7
+ }
8
+ export interface PublishBlogResult {
9
+ documentId: string;
10
+ slug: string;
11
+ published: boolean;
12
+ deployUrl?: string;
13
+ }
14
+ export interface BlogPostData {
15
+ title: string;
16
+ slug: string;
17
+ content: string;
18
+ site: string;
19
+ tags?: string[];
20
+ excerpt?: string;
21
+ }
22
+ export interface BlogPostSummary {
23
+ documentId: string;
24
+ title: string;
25
+ slug: string;
26
+ site: string;
27
+ createdAt: string;
28
+ }
29
+ /**
30
+ * Main orchestrator: find a blog post by slug, publish it in Strapi,
31
+ * and optionally deploy the portfolio site to Vercel.
32
+ *
33
+ * @throws If no blog post is found for the given slug.
34
+ *
35
+ * @example
36
+ * const result = await publishBlog({ slug: 'copper-investment-thesis-2026', deployAfter: true })
37
+ * console.log(result.deployUrl) // https://portfolio-2026.vercel.app
38
+ */
39
+ export declare function publishBlog(opts: PublishBlogOptions): Promise<PublishBlogResult>;
40
+ /**
41
+ * Create a new blog post draft in Strapi.
42
+ *
43
+ * @example
44
+ * const post = await createBlogPost({
45
+ * title: 'Copper Investment Thesis 2026',
46
+ * slug: 'copper-investment-thesis-2026',
47
+ * content: '## Overview\n...',
48
+ * site: 'portfolio',
49
+ * tags: ['Automated Report'],
50
+ * })
51
+ */
52
+ export declare function createBlogPost(data: BlogPostData): Promise<StrapiItem>;
53
+ /**
54
+ * List unpublished blog post drafts from Strapi, optionally filtered by site.
55
+ *
56
+ * @param site - Optional site key to filter by (e.g. 'portfolio', 'insurance').
57
+ *
58
+ * @example
59
+ * const drafts = await listBlogDrafts('portfolio')
60
+ * drafts.forEach(d => console.log(d.slug, d.createdAt))
61
+ */
62
+ export declare function listBlogDrafts(site?: string): Promise<BlogPostSummary[]>;
@@ -0,0 +1,74 @@
1
+ import 'dotenv/config';
2
+ import { deploy } from '../infra/deploy.js';
3
+ import { strapiGet, strapiPost, findBySlug, publish, } from './strapi-client.js';
4
+ // ── Functions ─────────────────────────────────────────────────────────
5
+ /**
6
+ * Main orchestrator: find a blog post by slug, publish it in Strapi,
7
+ * and optionally deploy the portfolio site to Vercel.
8
+ *
9
+ * @throws If no blog post is found for the given slug.
10
+ *
11
+ * @example
12
+ * const result = await publishBlog({ slug: 'copper-investment-thesis-2026', deployAfter: true })
13
+ * console.log(result.deployUrl) // https://portfolio-2026.vercel.app
14
+ */
15
+ export async function publishBlog(opts) {
16
+ const { slug, deployAfter = false } = opts;
17
+ const item = await findBySlug('blog-posts', slug);
18
+ if (!item) {
19
+ throw new Error(`Blog post not found for slug: "${slug}"`);
20
+ }
21
+ const { documentId } = item;
22
+ await publish('blog-posts', documentId);
23
+ let deployUrl;
24
+ if (deployAfter) {
25
+ deployUrl = await deploy('portfolio', true);
26
+ }
27
+ return {
28
+ documentId,
29
+ slug,
30
+ published: true,
31
+ ...(deployUrl !== undefined ? { deployUrl } : {}),
32
+ };
33
+ }
34
+ /**
35
+ * Create a new blog post draft in Strapi.
36
+ *
37
+ * @example
38
+ * const post = await createBlogPost({
39
+ * title: 'Copper Investment Thesis 2026',
40
+ * slug: 'copper-investment-thesis-2026',
41
+ * content: '## Overview\n...',
42
+ * site: 'portfolio',
43
+ * tags: ['Automated Report'],
44
+ * })
45
+ */
46
+ export async function createBlogPost(data) {
47
+ return strapiPost('/api/blog-posts', data);
48
+ }
49
+ /**
50
+ * List unpublished blog post drafts from Strapi, optionally filtered by site.
51
+ *
52
+ * @param site - Optional site key to filter by (e.g. 'portfolio', 'insurance').
53
+ *
54
+ * @example
55
+ * const drafts = await listBlogDrafts('portfolio')
56
+ * drafts.forEach(d => console.log(d.slug, d.createdAt))
57
+ */
58
+ export async function listBlogDrafts(site) {
59
+ const params = {
60
+ status: 'draft',
61
+ 'sort': 'createdAt:desc',
62
+ };
63
+ if (site) {
64
+ params['filters[site][$eq]'] = site;
65
+ }
66
+ const result = await strapiGet('/api/blog-posts', params);
67
+ return result.data.map((item) => ({
68
+ documentId: item.documentId,
69
+ title: String(item.title ?? ''),
70
+ slug: String(item.slug ?? ''),
71
+ site: String(item.site ?? ''),
72
+ createdAt: String(item.createdAt ?? ''),
73
+ }));
74
+ }
@@ -0,0 +1,123 @@
1
+ import 'dotenv/config';
2
+ export interface StrapiItem {
3
+ id: number;
4
+ documentId: string;
5
+ [key: string]: unknown;
6
+ }
7
+ export interface StrapiPagination {
8
+ page: number;
9
+ pageSize: number;
10
+ pageCount: number;
11
+ total: number;
12
+ }
13
+ export interface StrapiPage {
14
+ data: StrapiItem[];
15
+ meta: {
16
+ pagination: StrapiPagination;
17
+ };
18
+ }
19
+ export interface StrapiError {
20
+ status: number;
21
+ name: string;
22
+ message: string;
23
+ details?: Record<string, unknown>;
24
+ }
25
+ export declare class StrapiClientError extends Error {
26
+ status: number;
27
+ strapiError?: StrapiError | undefined;
28
+ constructor(message: string, status: number, strapiError?: StrapiError | undefined);
29
+ }
30
+ /**
31
+ * GET a Strapi endpoint with optional query params.
32
+ * Returns the full parsed JSON response.
33
+ *
34
+ * @example
35
+ * const result = await strapiGet('/api/newsletters', { 'status': 'draft' })
36
+ */
37
+ export declare function strapiGet<T = StrapiPage>(endpoint: string, params?: Record<string, string>): Promise<T>;
38
+ /**
39
+ * POST to a Strapi endpoint. Wraps data in `{ data }` per Strapi v5 convention.
40
+ * Returns the created item.
41
+ *
42
+ * @example
43
+ * const item = await strapiPost('/api/social-posts', {
44
+ * headline: 'New post',
45
+ * brand: 'LIFEINSUR',
46
+ * })
47
+ */
48
+ export declare function strapiPost(endpoint: string, data: Record<string, unknown>): Promise<StrapiItem>;
49
+ /**
50
+ * PUT to a Strapi endpoint by documentId. Wraps data in `{ data }`.
51
+ *
52
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
53
+ *
54
+ * @example
55
+ * await strapiPut('/api/newsletters', 'abc123-def456', { subject_line: 'Updated' })
56
+ */
57
+ export declare function strapiPut(endpoint: string, documentId: string, data: Record<string, unknown>): Promise<StrapiItem>;
58
+ /**
59
+ * DELETE a Strapi item by documentId.
60
+ *
61
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
62
+ *
63
+ * @example
64
+ * await strapiDelete('/api/social-posts', 'abc123-def456')
65
+ */
66
+ export declare function strapiDelete(endpoint: string, documentId: string): Promise<void>;
67
+ /**
68
+ * Upload a file to Strapi's `/api/upload` endpoint via multipart form.
69
+ *
70
+ * Optionally link the upload to an existing entry via `refData`:
71
+ * - ref: content type UID (e.g. 'api::newsletter.newsletter')
72
+ * - refId: documentId of the entry to attach to
73
+ * - field: field name on the content type (e.g. 'cover_image')
74
+ *
75
+ * @example
76
+ * const uploaded = await strapiUploadFile('/path/to/image.png')
77
+ * const linked = await strapiUploadFile('/path/to/cover.jpg', {
78
+ * ref: 'api::blog-post.blog-post',
79
+ * refId: 'abc123',
80
+ * field: 'cover',
81
+ * })
82
+ */
83
+ export declare function strapiUploadFile(filePath: string, refData?: {
84
+ ref: string;
85
+ refId: string;
86
+ field: string;
87
+ }): Promise<StrapiItem[]>;
88
+ /**
89
+ * List items of a content type filtered by brand, with optional status filter.
90
+ * This is the most common query pattern in Optimal's multi-brand CMS setup.
91
+ *
92
+ * Content types: 'newsletters', 'social-posts', 'blog-posts'
93
+ * Brands: 'CRE-11TRUST', 'LIFEINSUR'
94
+ * Status: 'draft' or 'published' (Strapi's draftAndPublish)
95
+ *
96
+ * @example
97
+ * const drafts = await listByBrand('social-posts', 'LIFEINSUR', 'draft')
98
+ * const published = await listByBrand('newsletters', 'CRE-11TRUST', 'published')
99
+ * const all = await listByBrand('blog-posts', 'CRE-11TRUST')
100
+ */
101
+ export declare function listByBrand(contentType: string, brand: string, status?: 'draft' | 'published'): Promise<StrapiPage>;
102
+ /**
103
+ * Find a single item by slug within a content type.
104
+ * Returns null if not found.
105
+ *
106
+ * @example
107
+ * const post = await findBySlug('blog-posts', 'copper-investment-thesis-2026')
108
+ */
109
+ export declare function findBySlug(contentType: string, slug: string): Promise<StrapiItem | null>;
110
+ /**
111
+ * Publish an item by setting publishedAt via PUT.
112
+ *
113
+ * @example
114
+ * await publish('newsletters', 'abc123-def456')
115
+ */
116
+ export declare function publish(contentType: string, documentId: string): Promise<StrapiItem>;
117
+ /**
118
+ * Unpublish (revert to draft) by clearing publishedAt.
119
+ *
120
+ * @example
121
+ * await unpublish('newsletters', 'abc123-def456')
122
+ */
123
+ export declare function unpublish(contentType: string, documentId: string): Promise<StrapiItem>;
@@ -0,0 +1,213 @@
1
+ import 'dotenv/config';
2
+ import { readFileSync } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ export class StrapiClientError extends Error {
5
+ status;
6
+ strapiError;
7
+ constructor(message, status, strapiError) {
8
+ super(message);
9
+ this.status = status;
10
+ this.strapiError = strapiError;
11
+ this.name = 'StrapiClientError';
12
+ }
13
+ }
14
+ // ── Config ───────────────────────────────────────────────────────────
15
+ function getConfig() {
16
+ const url = process.env.STRAPI_URL;
17
+ const token = process.env.STRAPI_API_TOKEN;
18
+ if (!url || !token) {
19
+ throw new Error('Missing env vars: STRAPI_URL, STRAPI_API_TOKEN');
20
+ }
21
+ return { url: url.replace(/\/+$/, ''), token };
22
+ }
23
+ // ── Internal request helper ──────────────────────────────────────────
24
+ async function request(path, opts = {}) {
25
+ const { url, token } = getConfig();
26
+ const fullUrl = `${url}${path}`;
27
+ const res = await fetch(fullUrl, {
28
+ ...opts,
29
+ headers: {
30
+ Authorization: `Bearer ${token}`,
31
+ 'Content-Type': 'application/json',
32
+ ...opts.headers,
33
+ },
34
+ });
35
+ if (!res.ok) {
36
+ let strapiErr;
37
+ try {
38
+ const body = await res.json();
39
+ strapiErr = body?.error;
40
+ }
41
+ catch {
42
+ // non-JSON error body
43
+ }
44
+ throw new StrapiClientError(strapiErr?.message ?? `Strapi ${res.status}: ${res.statusText}`, res.status, strapiErr);
45
+ }
46
+ if (res.status === 204)
47
+ return undefined;
48
+ return res.json();
49
+ }
50
+ // ── CRUD Functions ───────────────────────────────────────────────────
51
+ /**
52
+ * GET a Strapi endpoint with optional query params.
53
+ * Returns the full parsed JSON response.
54
+ *
55
+ * @example
56
+ * const result = await strapiGet('/api/newsletters', { 'status': 'draft' })
57
+ */
58
+ export async function strapiGet(endpoint, params) {
59
+ const qs = params ? new URLSearchParams(params).toString() : '';
60
+ const path = qs ? `${endpoint}?${qs}` : endpoint;
61
+ return request(path);
62
+ }
63
+ /**
64
+ * POST to a Strapi endpoint. Wraps data in `{ data }` per Strapi v5 convention.
65
+ * Returns the created item.
66
+ *
67
+ * @example
68
+ * const item = await strapiPost('/api/social-posts', {
69
+ * headline: 'New post',
70
+ * brand: 'LIFEINSUR',
71
+ * })
72
+ */
73
+ export async function strapiPost(endpoint, data) {
74
+ const result = await request(endpoint, {
75
+ method: 'POST',
76
+ body: JSON.stringify({ data }),
77
+ });
78
+ return result.data;
79
+ }
80
+ /**
81
+ * PUT to a Strapi endpoint by documentId. Wraps data in `{ data }`.
82
+ *
83
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
84
+ *
85
+ * @example
86
+ * await strapiPut('/api/newsletters', 'abc123-def456', { subject_line: 'Updated' })
87
+ */
88
+ export async function strapiPut(endpoint, documentId, data) {
89
+ const result = await request(`${endpoint}/${documentId}`, {
90
+ method: 'PUT',
91
+ body: JSON.stringify({ data }),
92
+ });
93
+ return result.data;
94
+ }
95
+ /**
96
+ * DELETE a Strapi item by documentId.
97
+ *
98
+ * IMPORTANT: Strapi v5 uses documentId (UUID string), NOT numeric id.
99
+ *
100
+ * @example
101
+ * await strapiDelete('/api/social-posts', 'abc123-def456')
102
+ */
103
+ export async function strapiDelete(endpoint, documentId) {
104
+ await request(`${endpoint}/${documentId}`, { method: 'DELETE' });
105
+ }
106
+ /**
107
+ * Upload a file to Strapi's `/api/upload` endpoint via multipart form.
108
+ *
109
+ * Optionally link the upload to an existing entry via `refData`:
110
+ * - ref: content type UID (e.g. 'api::newsletter.newsletter')
111
+ * - refId: documentId of the entry to attach to
112
+ * - field: field name on the content type (e.g. 'cover_image')
113
+ *
114
+ * @example
115
+ * const uploaded = await strapiUploadFile('/path/to/image.png')
116
+ * const linked = await strapiUploadFile('/path/to/cover.jpg', {
117
+ * ref: 'api::blog-post.blog-post',
118
+ * refId: 'abc123',
119
+ * field: 'cover',
120
+ * })
121
+ */
122
+ export async function strapiUploadFile(filePath, refData) {
123
+ const { url, token } = getConfig();
124
+ const fileBuffer = readFileSync(filePath);
125
+ const fileName = basename(filePath);
126
+ const formData = new FormData();
127
+ formData.append('files', new Blob([fileBuffer]), fileName);
128
+ if (refData) {
129
+ formData.append('ref', refData.ref);
130
+ formData.append('refId', refData.refId);
131
+ formData.append('field', refData.field);
132
+ }
133
+ const res = await fetch(`${url}/api/upload`, {
134
+ method: 'POST',
135
+ headers: {
136
+ Authorization: `Bearer ${token}`,
137
+ // Do NOT set Content-Type — fetch sets it with the multipart boundary
138
+ },
139
+ body: formData,
140
+ });
141
+ if (!res.ok) {
142
+ let strapiErr;
143
+ try {
144
+ const body = await res.json();
145
+ strapiErr = body?.error;
146
+ }
147
+ catch {
148
+ // non-JSON error body
149
+ }
150
+ throw new StrapiClientError(strapiErr?.message ?? `Upload failed ${res.status}: ${res.statusText}`, res.status, strapiErr);
151
+ }
152
+ return res.json();
153
+ }
154
+ // ── Convenience ──────────────────────────────────────────────────────
155
+ /**
156
+ * List items of a content type filtered by brand, with optional status filter.
157
+ * This is the most common query pattern in Optimal's multi-brand CMS setup.
158
+ *
159
+ * Content types: 'newsletters', 'social-posts', 'blog-posts'
160
+ * Brands: 'CRE-11TRUST', 'LIFEINSUR'
161
+ * Status: 'draft' or 'published' (Strapi's draftAndPublish)
162
+ *
163
+ * @example
164
+ * const drafts = await listByBrand('social-posts', 'LIFEINSUR', 'draft')
165
+ * const published = await listByBrand('newsletters', 'CRE-11TRUST', 'published')
166
+ * const all = await listByBrand('blog-posts', 'CRE-11TRUST')
167
+ */
168
+ export async function listByBrand(contentType, brand, status) {
169
+ const params = {
170
+ 'filters[brand][$eq]': brand,
171
+ 'sort': 'createdAt:desc',
172
+ };
173
+ if (status) {
174
+ params['status'] = status;
175
+ }
176
+ return strapiGet(`/api/${contentType}`, params);
177
+ }
178
+ /**
179
+ * Find a single item by slug within a content type.
180
+ * Returns null if not found.
181
+ *
182
+ * @example
183
+ * const post = await findBySlug('blog-posts', 'copper-investment-thesis-2026')
184
+ */
185
+ export async function findBySlug(contentType, slug) {
186
+ const result = await strapiGet(`/api/${contentType}`, {
187
+ 'filters[slug][$eq]': slug,
188
+ 'pagination[pageSize]': '1',
189
+ });
190
+ return result.data[0] ?? null;
191
+ }
192
+ /**
193
+ * Publish an item by setting publishedAt via PUT.
194
+ *
195
+ * @example
196
+ * await publish('newsletters', 'abc123-def456')
197
+ */
198
+ export async function publish(contentType, documentId) {
199
+ return strapiPut(`/api/${contentType}`, documentId, {
200
+ publishedAt: new Date().toISOString(),
201
+ });
202
+ }
203
+ /**
204
+ * Unpublish (revert to draft) by clearing publishedAt.
205
+ *
206
+ * @example
207
+ * await unpublish('newsletters', 'abc123-def456')
208
+ */
209
+ export async function unpublish(contentType, documentId) {
210
+ return strapiPut(`/api/${contentType}`, documentId, {
211
+ publishedAt: null,
212
+ });
213
+ }
@@ -0,0 +1,17 @@
1
+ import { getSupabase } from '../supabase.js';
2
+ import { type OptimalConfigV1 } from './schema.js';
3
+ export declare function setRegistrySupabaseProviderForTests(provider: typeof getSupabase): void;
4
+ export declare function resetRegistrySupabaseProviderForTests(): void;
5
+ export declare function ensureConfigDir(): Promise<void>;
6
+ export declare function getLocalConfigPath(): string;
7
+ export declare function getHistoryPath(): string;
8
+ export declare function readLocalConfig(): Promise<OptimalConfigV1 | null>;
9
+ export declare function writeLocalConfig(config: OptimalConfigV1): Promise<void>;
10
+ export declare function hashConfig(config: OptimalConfigV1): string;
11
+ export declare function appendHistory(entry: string): Promise<void>;
12
+ export type RegistrySyncResult = {
13
+ ok: boolean;
14
+ message: string;
15
+ };
16
+ export declare function pullRegistryProfile(profile?: string): Promise<RegistrySyncResult>;
17
+ export declare function pushRegistryProfile(profile?: string, force?: boolean, agent?: string): Promise<RegistrySyncResult>;
@@ -0,0 +1,182 @@
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 } from './schema.js';
8
+ const DIR = join(homedir(), '.optimal');
9
+ const LOCAL_CONFIG_PATH = join(DIR, 'optimal.config.json');
10
+ const HISTORY_PATH = join(DIR, 'config-history.log');
11
+ const REGISTRY_TABLE = 'cli_config_registry';
12
+ let supabaseProvider = getSupabase;
13
+ function getGlobalProviderOverride() {
14
+ const candidate = globalThis.__optimalRegistrySupabaseProvider;
15
+ return candidate ?? null;
16
+ }
17
+ function getActiveSupabaseProvider() {
18
+ return getGlobalProviderOverride() ?? supabaseProvider;
19
+ }
20
+ export function setRegistrySupabaseProviderForTests(provider) {
21
+ supabaseProvider = provider;
22
+ globalThis.__optimalRegistrySupabaseProvider = provider;
23
+ }
24
+ export function resetRegistrySupabaseProviderForTests() {
25
+ supabaseProvider = getSupabase;
26
+ delete globalThis.__optimalRegistrySupabaseProvider;
27
+ }
28
+ export async function ensureConfigDir() {
29
+ await mkdir(DIR, { recursive: true });
30
+ }
31
+ export function getLocalConfigPath() {
32
+ return LOCAL_CONFIG_PATH;
33
+ }
34
+ export function getHistoryPath() {
35
+ return HISTORY_PATH;
36
+ }
37
+ export async function readLocalConfig() {
38
+ if (!existsSync(LOCAL_CONFIG_PATH))
39
+ return null;
40
+ const raw = await readFile(LOCAL_CONFIG_PATH, 'utf-8');
41
+ const parsed = JSON.parse(raw);
42
+ return assertOptimalConfigV1(parsed);
43
+ }
44
+ export async function writeLocalConfig(config) {
45
+ await ensureConfigDir();
46
+ await writeFile(LOCAL_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, 'utf-8');
47
+ }
48
+ export function hashConfig(config) {
49
+ const payload = JSON.stringify(config);
50
+ return createHash('sha256').update(payload).digest('hex');
51
+ }
52
+ export async function appendHistory(entry) {
53
+ await ensureConfigDir();
54
+ await writeFile(HISTORY_PATH, `${entry}\n`, { encoding: 'utf-8', flag: 'a' });
55
+ }
56
+ function resolveOwner(local) {
57
+ return local?.profile.owner || process.env.OPTIMAL_CONFIG_OWNER || null;
58
+ }
59
+ function parseEpoch(input) {
60
+ if (!input)
61
+ return 0;
62
+ const ts = Date.parse(input);
63
+ return Number.isNaN(ts) ? 0 : ts;
64
+ }
65
+ export async function pullRegistryProfile(profile = 'default') {
66
+ try {
67
+ const local = await readLocalConfig();
68
+ const owner = resolveOwner(local);
69
+ if (!owner) {
70
+ return {
71
+ ok: false,
72
+ message: 'registry pull failed: missing owner (set local config profile.owner or OPTIMAL_CONFIG_OWNER)',
73
+ };
74
+ }
75
+ const supabase = getActiveSupabaseProvider()('optimal');
76
+ const { data, error } = await supabase
77
+ .from(REGISTRY_TABLE)
78
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
79
+ .eq('owner', owner)
80
+ .eq('profile', profile)
81
+ .maybeSingle();
82
+ if (error) {
83
+ return { ok: false, message: `registry pull failed: ${error.message}` };
84
+ }
85
+ if (!data) {
86
+ return { ok: false, message: `registry pull failed: no remote profile found for owner=${owner} profile=${profile}` };
87
+ }
88
+ const row = data;
89
+ const payload = assertOptimalConfigV1(row.payload);
90
+ await writeLocalConfig(payload);
91
+ const localHash = local ? hashConfig(local) : null;
92
+ const changed = localHash !== row.payload_hash;
93
+ return {
94
+ ok: true,
95
+ message: changed
96
+ ? `registry pull ok: wrote owner=${owner} profile=${profile} hash=${row.payload_hash.slice(0, 12)}`
97
+ : `registry pull ok: local already matched owner=${owner} profile=${profile}`,
98
+ };
99
+ }
100
+ catch (err) {
101
+ return {
102
+ ok: false,
103
+ message: `registry pull failed: ${err instanceof Error ? err.message : String(err)}`,
104
+ };
105
+ }
106
+ }
107
+ export async function pushRegistryProfile(profile = 'default', force = false, agent) {
108
+ try {
109
+ const local = await readLocalConfig();
110
+ if (!local) {
111
+ return { ok: false, message: `registry push failed: no local config at ${LOCAL_CONFIG_PATH}` };
112
+ }
113
+ let owner = resolveOwner(local);
114
+ if (!owner && agent) {
115
+ owner = agent;
116
+ local.profile.owner = owner;
117
+ }
118
+ if (!owner) {
119
+ return {
120
+ ok: false,
121
+ message: 'registry push failed: missing owner (set local config profile.owner, OPTIMAL_CONFIG_OWNER, or use --agent)',
122
+ };
123
+ }
124
+ const localHash = hashConfig(local);
125
+ const supabase = getActiveSupabaseProvider()('optimal');
126
+ const { data: existing, error: readErr } = await supabase
127
+ .from(REGISTRY_TABLE)
128
+ .select('owner,profile,config_version,payload,payload_hash,updated_at')
129
+ .eq('owner', owner)
130
+ .eq('profile', profile)
131
+ .maybeSingle();
132
+ if (readErr) {
133
+ return { ok: false, message: `registry push failed: ${readErr.message}` };
134
+ }
135
+ if (existing) {
136
+ const row = existing;
137
+ if (row.payload_hash !== localHash && !force) {
138
+ const remotePayload = assertOptimalConfigV1(row.payload);
139
+ const remoteTs = Math.max(parseEpoch(row.updated_at), parseEpoch(remotePayload.profile.updated_at));
140
+ const localTs = parseEpoch(local.profile.updated_at);
141
+ if (remoteTs >= localTs) {
142
+ return {
143
+ ok: false,
144
+ message: `registry push conflict: remote is newer/different for owner=${owner} profile=${profile}. ` +
145
+ 'run `optimal config sync pull` or retry with --force',
146
+ };
147
+ }
148
+ }
149
+ }
150
+ const payload = {
151
+ ...local,
152
+ profile: {
153
+ ...local.profile,
154
+ name: profile,
155
+ owner,
156
+ updated_at: new Date().toISOString(),
157
+ },
158
+ };
159
+ const { error: upsertErr } = await supabase.from(REGISTRY_TABLE).upsert({
160
+ owner,
161
+ profile,
162
+ config_version: payload.version,
163
+ payload,
164
+ payload_hash: hashConfig(payload),
165
+ source: 'optimal-cli',
166
+ updated_by: process.env.USER || 'oracle',
167
+ }, { onConflict: 'owner,profile' });
168
+ if (upsertErr) {
169
+ return { ok: false, message: `registry push failed: ${upsertErr.message}` };
170
+ }
171
+ return {
172
+ ok: true,
173
+ message: `registry push ok: owner=${owner} profile=${profile} hash=${hashConfig(payload).slice(0, 12)} force=${force}`,
174
+ };
175
+ }
176
+ catch (err) {
177
+ return {
178
+ ok: false,
179
+ message: `registry push failed: ${err instanceof Error ? err.message : String(err)}`,
180
+ };
181
+ }
182
+ }