backend-manager 5.0.202 → 5.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.
- package/CHANGELOG.md +61 -0
- package/CLAUDE.md +43 -1501
- package/docs/admin-post-route.md +24 -0
- package/docs/ai-library.md +23 -0
- package/docs/architecture.md +31 -0
- package/docs/auth-hooks.md +74 -0
- package/docs/cli-firestore-auth.md +59 -0
- package/docs/cli-logs.md +67 -0
- package/docs/code-patterns.md +67 -0
- package/docs/common-operations.md +64 -0
- package/docs/directory-structure.md +119 -0
- package/docs/environment-detection.md +7 -0
- package/docs/file-naming.md +11 -0
- package/docs/marketing-campaigns.md +244 -0
- package/docs/marketing-fields.md +25 -0
- package/docs/mcp.md +95 -0
- package/docs/payment-system.md +325 -0
- package/docs/response-headers.md +7 -0
- package/docs/routes.md +126 -0
- package/docs/sanitization.md +61 -0
- package/docs/schemas.md +39 -0
- package/docs/stripe-webhook-forwarding.md +18 -0
- package/docs/testing.md +129 -0
- package/docs/usage-rate-limiting.md +67 -0
- package/package.json +8 -4
- package/src/defaults/CHANGELOG.md +15 -0
- package/src/defaults/CLAUDE.md +8 -4
- package/src/defaults/docs/README.md +17 -0
- package/src/defaults/test/README.md +33 -0
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +48 -8
- package/src/manager/functions/core/actions/api/admin/create-post.js +3 -27
- package/src/manager/helpers/settings.js +26 -7
- package/src/manager/helpers/utilities.js +21 -0
- package/src/manager/index.js +1 -1
- package/src/manager/libraries/ai/index.js +162 -0
- package/src/manager/libraries/ai/providers/anthropic.js +193 -0
- package/src/manager/libraries/ai/providers/claude-code.js +206 -0
- package/src/manager/libraries/ai/providers/openai.js +934 -0
- package/src/manager/libraries/disposable-domains.json +2 -0
- package/src/manager/libraries/email/generators/lib/filter.js +179 -0
- package/src/manager/libraries/email/generators/lib/image-host.js +231 -0
- package/src/manager/libraries/email/generators/lib/mjml-template.js +83 -0
- package/src/manager/libraries/email/generators/lib/structure.js +278 -0
- package/src/manager/libraries/email/generators/lib/svg-illustrator.js +184 -0
- package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +63 -0
- package/src/manager/libraries/email/generators/lib/templates/clean.js +82 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial-helpers.js +100 -0
- package/src/manager/libraries/email/generators/lib/templates/editorial.js +317 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report-helpers.js +138 -0
- package/src/manager/libraries/email/generators/lib/templates/field-report.js +497 -0
- package/src/manager/libraries/email/generators/lib/templates/index.js +28 -0
- package/src/manager/libraries/email/generators/lib/templates/shared.js +534 -0
- package/src/manager/libraries/email/generators/newsletter.js +377 -95
- package/src/manager/libraries/email/marketing/index.js +5 -2
- package/src/manager/libraries/email/providers/beehiiv.js +7 -3
- package/src/manager/libraries/openai.js +13 -932
- package/src/manager/routes/admin/post/deduplicate-image-alts.js +52 -0
- package/src/manager/routes/admin/post/post.js +10 -17
- package/templates/_.env +4 -0
- package/templates/_.gitignore +1 -0
- package/templates/backend-manager-config.json +48 -4
- package/test/helpers/slugify.js +394 -0
- package/test/marketing/fixtures/clean.json +31 -0
- package/test/marketing/fixtures/editorial.json +31 -0
- package/test/marketing/fixtures/field-report.json +54 -0
- package/test/marketing/newsletter-generate.js +731 -0
- package/test/marketing/newsletter-templates.js +512 -0
- package/test/routes/admin/deduplicate-image-alts.js +190 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Newsletter generation iteration test.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
*
|
|
6
|
+
* 1. FIXTURE MODE (default — runs in every test invocation, no env required)
|
|
7
|
+
* Loads a hand-crafted structure JSON from test/marketing/fixtures/<template>.json
|
|
8
|
+
* and renders it through the active template. No AI, no source fetching,
|
|
9
|
+
* no images, ~25-50ms render time, $0 cost. This is what runs in CI and
|
|
10
|
+
* what you iterate against for layout/CSS changes.
|
|
11
|
+
*
|
|
12
|
+
* Fixture-name resolution priority:
|
|
13
|
+
* 1. NEWSLETTER_FIXTURE=<name> (explicit override)
|
|
14
|
+
* 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
|
|
15
|
+
* 3. config.marketing.beehiiv.content.template (use the active brand's template)
|
|
16
|
+
* 4. 'clean' (universal fallback)
|
|
17
|
+
*
|
|
18
|
+
* Add a new template? Drop a matching JSON in test/marketing/fixtures/<name>.json.
|
|
19
|
+
* The fixture's content shape MUST match the template's `schema` export.
|
|
20
|
+
*
|
|
21
|
+
* 2. AI PIPELINE MODE (set TEST_EXTENDED_MODE=1)
|
|
22
|
+
* Pulls real sources from the parent BEM server, runs them through the
|
|
23
|
+
* structure → SVG → MJML pipeline, and writes a preview HTML. Same code
|
|
24
|
+
* path the daily pre-generation cron uses. Costs money (AI tokens). Use
|
|
25
|
+
* this when you want to evaluate prompt quality against real sources.
|
|
26
|
+
*
|
|
27
|
+
* Output (both modes): <projectRoot>/.temp/newsletter/run-<timestamp>/ (one level above functions/)
|
|
28
|
+
*
|
|
29
|
+
* Run from somiibo-backend/functions:
|
|
30
|
+
* npx mgr test project:marketing/newsletter-generate.js # fixture (fast, free)
|
|
31
|
+
* NEWSLETTER_FIXTURE=editorial npx mgr test project:marketing/newsletter-generate.js
|
|
32
|
+
* TEST_EXTENDED_MODE=1 npx mgr test project:marketing/newsletter-generate.js # AI pipeline
|
|
33
|
+
*
|
|
34
|
+
* --- Env vars (most apply to AI mode only) ---
|
|
35
|
+
* NEWSLETTER_FIXTURE=<name> Load and render a specific fixture (fixture mode).
|
|
36
|
+
* NEWSLETTER_TEMPLATE=<name> Override the layout template for this run.
|
|
37
|
+
* NEWSLETTER_THEME_ONLY=1 Reuse the most recent AI run's structure.json + PNGs
|
|
38
|
+
* and re-render. Different from FIXTURE: this loads
|
|
39
|
+
* prior AI output, FIXTURE loads hand-crafted JSON.
|
|
40
|
+
* NEWSLETTER_REUSE_RUN=<dir> Pair with THEME_ONLY: reuse a specific run dir.
|
|
41
|
+
* NEWSLETTER_NO_OPEN=1 Don't auto-open the preview in the browser.
|
|
42
|
+
*
|
|
43
|
+
* --- AI-mode-only env vars (require TEST_EXTENDED_MODE=1) ---
|
|
44
|
+
* NEWSLETTER_PEEK=1 Fetch + list ready sources, do not claim, exit.
|
|
45
|
+
* NEWSLETTER_SOURCE_ID=<id> Generate from one specific source WITHOUT claiming it.
|
|
46
|
+
* NEWSLETTER_LIMIT=10 Sources per category for PEEK (default 10).
|
|
47
|
+
* NEWSLETTER_RELEASE=1 Reset locally-tracked claimed sources back to 'ready'.
|
|
48
|
+
* NEWSLETTER_NO_IMAGES=1 Skip SVG/PNG generation (fast iteration on copy only).
|
|
49
|
+
* NEWSLETTER_PROVIDER_STRUCTURE=X Override structure provider (openai|anthropic).
|
|
50
|
+
* NEWSLETTER_PROVIDER_SVG=X Override SVG provider (openai|anthropic).
|
|
51
|
+
* NEWSLETTER_CAMPAIGN_ID=<id> Override the auto-generated campaign ID (folder name in newsletter-assets).
|
|
52
|
+
*
|
|
53
|
+
* In EXTENDED mode, the test ALWAYS uploads PNGs + newsletter.html to GitHub
|
|
54
|
+
* and creates a Beehiiv draft — same side effects as the production cron.
|
|
55
|
+
* No opt-out, no per-side-effect flag. EXTENDED = production-equivalent run.
|
|
56
|
+
*
|
|
57
|
+
* --- THEME_ONLY-mode-only env vars ---
|
|
58
|
+
* NEWSLETTER_BEEHIIV_UPLOAD=1 After re-rendering, upload as a new Beehiiv draft (rare).
|
|
59
|
+
*
|
|
60
|
+
* AI mode requires:
|
|
61
|
+
* BACKEND_MANAGER_KEY — authenticates with parent as admin
|
|
62
|
+
* OPENAI_API_KEY — structure provider (or BACKEND_MANAGER_OPENAI_API_KEY)
|
|
63
|
+
* ANTHROPIC_API_KEY — SVG provider (or BACKEND_MANAGER_ANTHROPIC_API_KEY)
|
|
64
|
+
* PARENT_API_URL — or set `parent` in backend-manager-config.json
|
|
65
|
+
*
|
|
66
|
+
* Fixture mode requires: nothing.
|
|
67
|
+
*/
|
|
68
|
+
const path = require('path');
|
|
69
|
+
const { execSync } = require('child_process');
|
|
70
|
+
const fetch = require('wonderful-fetch');
|
|
71
|
+
const jetpack = require('fs-jetpack');
|
|
72
|
+
|
|
73
|
+
module.exports = {
|
|
74
|
+
description: 'Generate a newsletter preview (fixture by default, full AI pipeline with TEST_EXTENDED_MODE)',
|
|
75
|
+
auth: 'none',
|
|
76
|
+
timeout: 180000, // 3 min — AI + parallel SVG calls + rasterization
|
|
77
|
+
// The test ALWAYS runs. By default it renders a hand-crafted fixture for the
|
|
78
|
+
// active template (fast, free, deterministic — catches layout regressions in
|
|
79
|
+
// CI). Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline that fetches
|
|
80
|
+
// real sources, calls the structure + SVG providers, and writes a preview.
|
|
81
|
+
// Other modes (FIXTURE, THEME_ONLY, RELEASE, PEEK) are also opt-in via env.
|
|
82
|
+
async run({ assert, config }) {
|
|
83
|
+
const env = process.env;
|
|
84
|
+
|
|
85
|
+
// --- Apply env overrides into newsletterConfig ---
|
|
86
|
+
// Newsletter config now lives under marketing.beehiiv.content.
|
|
87
|
+
const newsletterConfig = JSON.parse(JSON.stringify(config.marketing?.beehiiv?.content || {}));
|
|
88
|
+
|
|
89
|
+
if (env.NEWSLETTER_PROVIDER_STRUCTURE) {
|
|
90
|
+
newsletterConfig.provider = { ...(newsletterConfig.provider || {}), structure: env.NEWSLETTER_PROVIDER_STRUCTURE };
|
|
91
|
+
}
|
|
92
|
+
if (env.NEWSLETTER_PROVIDER_SVG) {
|
|
93
|
+
newsletterConfig.provider = { ...(newsletterConfig.provider || {}), svg: env.NEWSLETTER_PROVIDER_SVG };
|
|
94
|
+
}
|
|
95
|
+
if (env.NEWSLETTER_TEMPLATE) {
|
|
96
|
+
newsletterConfig.template = env.NEWSLETTER_TEMPLATE;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- Build the output dir ---
|
|
100
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-(\d{3})Z$/, '');
|
|
101
|
+
// .temp lives at the CONSUMER PROJECT ROOT (e.g. somiibo-backend/.temp), not
|
|
102
|
+
// inside functions/. Matches the convention used by UJM, BXM, electron-manager,
|
|
103
|
+
// etc. — every project's transient cache directory sits at the repo root.
|
|
104
|
+
const outRoot = path.join(process.cwd(), '..', '.temp', 'newsletter');
|
|
105
|
+
const runDir = path.join(outRoot, `run-${stamp}`);
|
|
106
|
+
const claimedFile = path.join(outRoot, '.claimed.json');
|
|
107
|
+
|
|
108
|
+
jetpack.dir(runDir);
|
|
109
|
+
|
|
110
|
+
// --- Release mode (early return) ---
|
|
111
|
+
if (env.NEWSLETTER_RELEASE) {
|
|
112
|
+
const released = await releaseAll(claimedFile);
|
|
113
|
+
console.log(`Released ${released} previously claimed source(s) back to ready`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Fixture mode ---
|
|
118
|
+
// Default behavior of this test. Loads a hand-crafted structure JSON from
|
|
119
|
+
// test/marketing/fixtures/{name}.json and renders it directly. No AI, no
|
|
120
|
+
// source fetching, no images. Used as the deterministic "predictable
|
|
121
|
+
// preview" loop AND as the default test-suite render.
|
|
122
|
+
//
|
|
123
|
+
// Fixture-name resolution priority:
|
|
124
|
+
// 1. NEWSLETTER_FIXTURE=<name> (explicit override)
|
|
125
|
+
// 2. NEWSLETTER_TEMPLATE=<name> (use the fixture for the override template)
|
|
126
|
+
// 3. newsletterConfig.template (use the fixture for the active brand's template)
|
|
127
|
+
// 4. 'clean' (universal fallback)
|
|
128
|
+
//
|
|
129
|
+
// This means a fresh `npx mgr test` against any consumer project picks the
|
|
130
|
+
// brand's configured template, renders the matching fixture, and produces
|
|
131
|
+
// a deterministic preview HTML — no AI, no money spent.
|
|
132
|
+
//
|
|
133
|
+
// The full AI pipeline only runs when TEST_EXTENDED_MODE=1 is set (below).
|
|
134
|
+
// Explicit NEWSLETTER_FIXTURE wins even in EXTENDED mode (you can ask for
|
|
135
|
+
// a fixture render mid-AI-iteration without re-cycling envs).
|
|
136
|
+
// NEWSLETTER_THEME_ONLY takes precedence below — it's a no-AI mode that
|
|
137
|
+
// reuses prior AI output, distinct from fixture mode.
|
|
138
|
+
if (env.NEWSLETTER_THEME_ONLY) {
|
|
139
|
+
// Fall through to the theme-only block below.
|
|
140
|
+
} else if (!env.TEST_EXTENDED_MODE || env.NEWSLETTER_FIXTURE) {
|
|
141
|
+
const requestedFixture = (env.NEWSLETTER_FIXTURE || env.NEWSLETTER_TEMPLATE || newsletterConfig.template || 'clean')
|
|
142
|
+
.replace(/^fixture:/, '');
|
|
143
|
+
const fixturePath = path.join(__dirname, 'fixtures', `${requestedFixture}.json`);
|
|
144
|
+
|
|
145
|
+
assert.ok(jetpack.exists(fixturePath),
|
|
146
|
+
`Fixture not found: ${fixturePath}. Available fixtures: ${jetpack.list(path.join(__dirname, 'fixtures')).filter((f) => f.endsWith('.json')).join(', ')}. Every registered template should ship a matching fixture in test/marketing/fixtures/.`);
|
|
147
|
+
|
|
148
|
+
const structure = jetpack.read(fixturePath, 'json');
|
|
149
|
+
|
|
150
|
+
assert.ok(structure, `Fixture ${requestedFixture}.json failed to parse as JSON`);
|
|
151
|
+
|
|
152
|
+
// Pin the template to the fixture name — fixtures are authored for a
|
|
153
|
+
// specific template's content shape, so this prevents the obvious bug
|
|
154
|
+
// of "render the field-report fixture through the clean template".
|
|
155
|
+
// Explicit NEWSLETTER_TEMPLATE override is still honored above.
|
|
156
|
+
if (!env.NEWSLETTER_TEMPLATE) {
|
|
157
|
+
newsletterConfig.template = requestedFixture;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
161
|
+
|
|
162
|
+
const renderStart = Date.now();
|
|
163
|
+
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
164
|
+
brand: config.brand,
|
|
165
|
+
newsletterConfig,
|
|
166
|
+
structure,
|
|
167
|
+
imagePaths: [],
|
|
168
|
+
campaign: `fixture-${requestedFixture}`,
|
|
169
|
+
});
|
|
170
|
+
const renderMs = Date.now() - renderStart;
|
|
171
|
+
|
|
172
|
+
const previewPath = path.join(runDir, 'newsletter.html');
|
|
173
|
+
jetpack.write(previewPath, html);
|
|
174
|
+
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
175
|
+
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
176
|
+
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
177
|
+
mode: 'fixture',
|
|
178
|
+
fixture: requestedFixture,
|
|
179
|
+
template: templateName,
|
|
180
|
+
renderMs,
|
|
181
|
+
timestamp: new Date().toISOString(),
|
|
182
|
+
}, null, 2));
|
|
183
|
+
|
|
184
|
+
console.log(`\n[fixture=${requestedFixture}] Rendered in ${renderMs}ms using template: ${templateName}`);
|
|
185
|
+
console.log(`[fixture=${requestedFixture}] Preview: ${previewPath}`);
|
|
186
|
+
console.log(`[fixture=${requestedFixture}] (Set TEST_EXTENDED_MODE=1 to switch to the full AI pipeline.)`);
|
|
187
|
+
|
|
188
|
+
if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
|
|
189
|
+
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
assert.ok(html.includes('<html'), 'Rendered HTML');
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// --- Theme-only mode (early return) ---
|
|
197
|
+
// Reuse a previous run's structure.json + PNGs to re-render MJML/HTML only.
|
|
198
|
+
// Sub-second iteration on layout / theme tokens with no AI cost.
|
|
199
|
+
if (env.NEWSLETTER_THEME_ONLY) {
|
|
200
|
+
const sourceRun = resolveReuseRun({
|
|
201
|
+
outRoot,
|
|
202
|
+
explicit: env.NEWSLETTER_REUSE_RUN,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assert.ok(sourceRun, 'Found a previous run to reuse (set NEWSLETTER_REUSE_RUN=run-<stamp> if needed)');
|
|
206
|
+
|
|
207
|
+
console.log(`[theme-only] reusing structure + images from: ${path.basename(sourceRun)}`);
|
|
208
|
+
|
|
209
|
+
const structure = jetpack.read(path.join(sourceRun, 'structure.json'), 'json');
|
|
210
|
+
|
|
211
|
+
assert.ok(structure?.sections?.length, 'Previous run has a valid structure.json');
|
|
212
|
+
|
|
213
|
+
// Copy section PNGs from previous run into this run's dir so paths resolve
|
|
214
|
+
const imagePaths = [];
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < structure.sections.length; i++) {
|
|
217
|
+
const filename = `section-${i + 1}.png`;
|
|
218
|
+
const srcPath = path.join(sourceRun, filename);
|
|
219
|
+
|
|
220
|
+
if (jetpack.exists(srcPath)) {
|
|
221
|
+
jetpack.copy(srcPath, path.join(runDir, filename));
|
|
222
|
+
imagePaths.push(`./${filename}`);
|
|
223
|
+
} else {
|
|
224
|
+
imagePaths.push(null);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const { renderNewsletter } = require('../../src/manager/libraries/email/generators/lib/mjml-template.js');
|
|
229
|
+
|
|
230
|
+
const renderStart = Date.now();
|
|
231
|
+
const { html, mjml, template: templateName } = await renderNewsletter({
|
|
232
|
+
brand: config.brand,
|
|
233
|
+
newsletterConfig,
|
|
234
|
+
structure,
|
|
235
|
+
imagePaths,
|
|
236
|
+
campaign: `theme-only-${stamp}`,
|
|
237
|
+
});
|
|
238
|
+
const renderMs = Date.now() - renderStart;
|
|
239
|
+
|
|
240
|
+
const previewPath = path.join(runDir, 'newsletter.html');
|
|
241
|
+
jetpack.write(previewPath, html);
|
|
242
|
+
jetpack.write(path.join(runDir, 'newsletter.mjml'), mjml || '');
|
|
243
|
+
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(structure, null, 2));
|
|
244
|
+
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify({
|
|
245
|
+
mode: 'theme-only',
|
|
246
|
+
reusedFrom: path.basename(sourceRun),
|
|
247
|
+
template: templateName,
|
|
248
|
+
renderMs,
|
|
249
|
+
timestamp: new Date().toISOString(),
|
|
250
|
+
}, null, 2));
|
|
251
|
+
|
|
252
|
+
console.log(`\n[theme-only] Rendered in ${renderMs}ms using template: ${templateName}`);
|
|
253
|
+
console.log(`[theme-only] Preview: ${previewPath}`);
|
|
254
|
+
|
|
255
|
+
// Optional: upload to Beehiiv as a draft
|
|
256
|
+
if (env.NEWSLETTER_BEEHIIV_UPLOAD) {
|
|
257
|
+
await uploadDraftToBeehiiv({
|
|
258
|
+
html,
|
|
259
|
+
structure,
|
|
260
|
+
config,
|
|
261
|
+
assistant: console,
|
|
262
|
+
runDir,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
|
|
267
|
+
try { execSync(`open "${previewPath}"`); } catch (e) { /* no-op */ }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
assert.ok(html.includes('<html'), 'Rendered HTML');
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- AI pipeline path (TEST_EXTENDED_MODE) ---
|
|
275
|
+
// Everything below this point talks to the real parent server and the AI
|
|
276
|
+
// providers. The parent URL is required for any of it.
|
|
277
|
+
const parentUrl = env.PARENT_API_URL || config.parent;
|
|
278
|
+
assert.ok(parentUrl, 'PARENT_API_URL (env) or parent (config) must be set for the AI pipeline. Set TEST_EXTENDED_MODE=1 to run it, or omit TEST_EXTENDED_MODE for the fast fixture preview.');
|
|
279
|
+
|
|
280
|
+
// --- Peek mode (early return) ---
|
|
281
|
+
if (env.NEWSLETTER_PEEK) {
|
|
282
|
+
const sources = await peekSources({
|
|
283
|
+
parentUrl,
|
|
284
|
+
categories: newsletterConfig.categories || [],
|
|
285
|
+
limit: parseInt(env.NEWSLETTER_LIMIT, 10) || 10,
|
|
286
|
+
key: env.BACKEND_MANAGER_KEY,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
console.log(`\nPeek mode — ${sources.length} ready source(s):\n`);
|
|
290
|
+
sources.forEach((s, i) => {
|
|
291
|
+
const raw = s.source || {};
|
|
292
|
+
const cats = (s.categories || []).join(', ') || s.category || '(none)';
|
|
293
|
+
console.log(`[${i + 1}] ${s.id}`);
|
|
294
|
+
console.log(` Categories: ${cats}`);
|
|
295
|
+
console.log(` From: ${raw.from || s.from || '(unknown)'}`);
|
|
296
|
+
console.log(` Subject: ${raw.subject || s.subject || '(none)'}`);
|
|
297
|
+
console.log(` Headline: ${s.ai?.headline || '(none — not yet AI-processed)'}`);
|
|
298
|
+
console.log('');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
assert.ok(true, `Peeked ${sources.length} sources`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- Fetch sources (real claim) ---
|
|
306
|
+
const sources = await fetchSourcesForRun({
|
|
307
|
+
parentUrl,
|
|
308
|
+
newsletterConfig,
|
|
309
|
+
brandId: config.brand?.id,
|
|
310
|
+
sourceId: env.NEWSLETTER_SOURCE_ID,
|
|
311
|
+
key: env.BACKEND_MANAGER_KEY,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
assert.ok(sources.length > 0, 'Fetched at least one source from parent server');
|
|
315
|
+
|
|
316
|
+
// Track claimed IDs for later --release-all
|
|
317
|
+
appendClaimed(claimedFile, sources.map((s) => s.id));
|
|
318
|
+
|
|
319
|
+
// --- Build a stub Manager + assistant to call the BEM generator library ---
|
|
320
|
+
// Newsletter config nests under beehiiv. Force `beehiiv.enabled: true` because
|
|
321
|
+
// the iteration test IS the explicit trigger — we're not checking whether
|
|
322
|
+
// beehiiv is configured for prod use, we're driving the generator directly.
|
|
323
|
+
const Manager = buildManagerStub({
|
|
324
|
+
...config,
|
|
325
|
+
marketing: {
|
|
326
|
+
...config.marketing,
|
|
327
|
+
beehiiv: {
|
|
328
|
+
...(config.marketing?.beehiiv || {}),
|
|
329
|
+
enabled: true,
|
|
330
|
+
content: newsletterConfig,
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
const assistant = buildAssistantStub(Manager);
|
|
335
|
+
|
|
336
|
+
// --- Run the production generator with the local-persist image hook ---
|
|
337
|
+
const generator = require('../../src/manager/libraries/email/generators/newsletter.js');
|
|
338
|
+
|
|
339
|
+
// EXTENDED mode is a MIRROR of the production cron — no escape hatches.
|
|
340
|
+
// GH upload always happens (PNGs + newsletter.html), Beehiiv draft upload
|
|
341
|
+
// always happens (governed inside newsletter.js by beehiiv.enabled, which
|
|
342
|
+
// we force true above). If you don't want the side effects, run fixture
|
|
343
|
+
// mode instead.
|
|
344
|
+
//
|
|
345
|
+
// persistImage is a side-effect callback that writes PNG+SVG to runDir for
|
|
346
|
+
// local preview / debug. Its return value is ignored when imageHost: 'github'
|
|
347
|
+
// because the generator uses the uploaded CDN URLs in the rendered HTML.
|
|
348
|
+
const persistImage = async (image, idx) => {
|
|
349
|
+
const filename = `section-${idx + 1}.png`;
|
|
350
|
+
const svgFilename = `section-${idx + 1}.svg`;
|
|
351
|
+
|
|
352
|
+
jetpack.write(path.join(runDir, filename), image.png);
|
|
353
|
+
jetpack.write(path.join(runDir, svgFilename), image.svg);
|
|
354
|
+
|
|
355
|
+
return `./${filename}`;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
// The campaignId becomes the GitHub folder name. Either pinned via env
|
|
359
|
+
// (for repeatable iteration on the same folder) or auto-generated by
|
|
360
|
+
// newsletter.generate() in Firestore auto-ID shape.
|
|
361
|
+
const campaignId = env.NEWSLETTER_CAMPAIGN_ID || undefined;
|
|
362
|
+
|
|
363
|
+
console.log(`[extended] uploading to itw-creative-works/newsletter-assets/${config.brand?.id}/${campaignId || '<auto-id>'}/ + Beehiiv draft`);
|
|
364
|
+
|
|
365
|
+
const result = await generator.generate(
|
|
366
|
+
Manager,
|
|
367
|
+
assistant,
|
|
368
|
+
{ name: `Somiibo Newsletter — Iteration ${stamp}` },
|
|
369
|
+
{
|
|
370
|
+
sources,
|
|
371
|
+
skipClaim: true, // We manage the claim/release lifecycle ourselves
|
|
372
|
+
skipImages: !!env.NEWSLETTER_NO_IMAGES,
|
|
373
|
+
// Local disk persistence runs unconditionally (for preview/debug)
|
|
374
|
+
persistImage,
|
|
375
|
+
// EXTENDED always uploads to GitHub — mirrors production cron exactly
|
|
376
|
+
imageHost: 'github',
|
|
377
|
+
campaignId,
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
assert.ok(result, 'Generator returned a result');
|
|
382
|
+
assert.ok(result.contentHtml, 'Generator returned contentHtml');
|
|
383
|
+
assert.ok(result.structure?.sections?.length >= 2, 'Has at least 2 sections');
|
|
384
|
+
|
|
385
|
+
// --- Write outputs ---
|
|
386
|
+
const previewPath = path.join(runDir, 'newsletter.html');
|
|
387
|
+
jetpack.write(previewPath, result.contentHtml);
|
|
388
|
+
jetpack.write(path.join(runDir, 'structure.json'), JSON.stringify(result.structure, null, 2));
|
|
389
|
+
jetpack.write(path.join(runDir, 'newsletter.mjml'), result.mjml || '');
|
|
390
|
+
jetpack.write(path.join(runDir, 'metadata.json'), JSON.stringify(result.meta || {}, null, 2));
|
|
391
|
+
|
|
392
|
+
console.log(`\nNewsletter preview written: ${previewPath}`);
|
|
393
|
+
console.log(`Subject: ${result.subject}`);
|
|
394
|
+
console.log(`Preheader: ${result.preheader}`);
|
|
395
|
+
console.log(`Sections: ${result.structure.sections.length}`);
|
|
396
|
+
if (result.meta?.totals) {
|
|
397
|
+
const t = result.meta.totals;
|
|
398
|
+
console.log(`\nRun summary:`);
|
|
399
|
+
console.log(` Total duration: ${(result.meta.totalDurationMs / 1000).toFixed(1)}s`);
|
|
400
|
+
console.log(` AI calls: ${t.aiCalls}`);
|
|
401
|
+
console.log(` Tokens: ${t.inputTokens} in / ${t.outputTokens} out (${t.totalTokens} total)`);
|
|
402
|
+
console.log(` Cost: $${t.totalCostUSD}`);
|
|
403
|
+
console.log(` Filter: provider=${result.meta.steps.filter?.provider} model=${result.meta.steps.filter?.model} (${result.meta.steps.filter?.durationMs}ms)`);
|
|
404
|
+
console.log(` Structure: provider=${result.meta.steps.structure?.provider} model=${result.meta.steps.structure?.model} (${result.meta.steps.structure?.durationMs}ms)`);
|
|
405
|
+
for (const img of result.meta.steps.images || []) {
|
|
406
|
+
console.log(` Image ${img.section}: provider=${img.provider} model=${img.model} (${img.durationMs}ms${img.fallback ? ', FALLBACK' : ''})`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Beehiiv draft upload already happened inside generator.generate() —
|
|
411
|
+
// newsletter.js calls beehiiv.createPost(draft) after the GH HTML upload.
|
|
412
|
+
// Look for assets.beehiivPostId in the result (null on free plan; non-null
|
|
413
|
+
// once the Beehiiv account is on Enterprise).
|
|
414
|
+
if (result.assets?.beehiivPostId) {
|
|
415
|
+
console.log(`Beehiiv: draft post created — id=${result.assets.beehiivPostId}`);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// --- Auto-open in browser (macOS) ---
|
|
419
|
+
if (!env.NEWSLETTER_NO_OPEN && process.platform === 'darwin') {
|
|
420
|
+
try {
|
|
421
|
+
execSync(`open "${previewPath}"`);
|
|
422
|
+
} catch (e) {
|
|
423
|
+
console.warn('Failed to auto-open preview:', e.message);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Upload the rendered HTML to Beehiiv as a draft post (never sends). Uses the
|
|
431
|
+
* v2 Posts API directly so it works against the test's stub Manager.
|
|
432
|
+
*
|
|
433
|
+
* Writes the Beehiiv response to {runDir}/beehiiv-upload.json for inspection.
|
|
434
|
+
*
|
|
435
|
+
* Required env: BEEHIIV_API_KEY
|
|
436
|
+
* Required config: marketing.beehiiv.publicationId (or we fuzzy-match by brand name)
|
|
437
|
+
*/
|
|
438
|
+
async function uploadDraftToBeehiiv({ html, structure, config, runDir }) {
|
|
439
|
+
const apiKey = process.env.BEEHIIV_API_KEY;
|
|
440
|
+
|
|
441
|
+
if (!apiKey) {
|
|
442
|
+
console.warn('[beehiiv] Skipping upload — BEEHIIV_API_KEY not set');
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const BASE_URL = 'https://api.beehiiv.com/v2';
|
|
447
|
+
const headers = { 'Authorization': `Bearer ${apiKey}` };
|
|
448
|
+
|
|
449
|
+
// Resolve publication ID — config first, then fuzzy-match by brand name
|
|
450
|
+
let publicationId = config?.marketing?.beehiiv?.publicationId;
|
|
451
|
+
const brandName = config?.brand?.name;
|
|
452
|
+
|
|
453
|
+
if (!publicationId && brandName) {
|
|
454
|
+
try {
|
|
455
|
+
const pubs = await fetch(`${BASE_URL}/publications?limit=100`, {
|
|
456
|
+
response: 'json',
|
|
457
|
+
headers,
|
|
458
|
+
timeout: 10000,
|
|
459
|
+
});
|
|
460
|
+
const brandLower = brandName.toLowerCase();
|
|
461
|
+
const matched = (pubs.data || []).find((p) =>
|
|
462
|
+
p.name.toLowerCase() === brandLower
|
|
463
|
+
|| p.name.toLowerCase().includes(brandLower)
|
|
464
|
+
|| brandLower.includes(p.name.toLowerCase())
|
|
465
|
+
);
|
|
466
|
+
publicationId = matched?.id;
|
|
467
|
+
} catch (e) {
|
|
468
|
+
console.error('[beehiiv] Publication lookup failed:', e.message);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!publicationId) {
|
|
473
|
+
console.warn(`[beehiiv] Skipping upload — could not resolve publication ID (brand=${brandName})`);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Build the post — always draft from the test (never sends)
|
|
478
|
+
const body = {
|
|
479
|
+
title: structure.subject || `Newsletter ${new Date().toISOString()}`,
|
|
480
|
+
status: 'draft',
|
|
481
|
+
body_content: html,
|
|
482
|
+
email_settings: {
|
|
483
|
+
subject_line: structure.subject,
|
|
484
|
+
preview_text: structure.preheader || '',
|
|
485
|
+
},
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
console.log(`[beehiiv] Uploading draft to publication ${publicationId}...`);
|
|
489
|
+
|
|
490
|
+
try {
|
|
491
|
+
const data = await fetch(`${BASE_URL}/publications/${publicationId}/posts`, {
|
|
492
|
+
method: 'post',
|
|
493
|
+
response: 'json',
|
|
494
|
+
headers,
|
|
495
|
+
timeout: 30000,
|
|
496
|
+
body,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
if (data.data?.id) {
|
|
500
|
+
const editUrl = `https://app.beehiiv.com/posts/${data.data.id}`;
|
|
501
|
+
console.log(`[beehiiv] ✓ Draft uploaded: ${data.data.id}`);
|
|
502
|
+
console.log(`[beehiiv] Edit at: ${editUrl}`);
|
|
503
|
+
jetpack.write(path.join(runDir, 'beehiiv-upload.json'), JSON.stringify({
|
|
504
|
+
publicationId,
|
|
505
|
+
postId: data.data.id,
|
|
506
|
+
editUrl,
|
|
507
|
+
status: 'draft',
|
|
508
|
+
uploadedAt: new Date().toISOString(),
|
|
509
|
+
response: data.data,
|
|
510
|
+
}, null, 2));
|
|
511
|
+
} else {
|
|
512
|
+
console.error('[beehiiv] Upload returned no post ID:', JSON.stringify(data));
|
|
513
|
+
}
|
|
514
|
+
} catch (e) {
|
|
515
|
+
console.error('[beehiiv] Upload failed:', e.message);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- Helpers ---
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* GET /newsletter/sources without claiming (claimFor omitted).
|
|
523
|
+
*/
|
|
524
|
+
async function peekSources({ parentUrl, categories, limit, key }) {
|
|
525
|
+
if (!categories.length) {
|
|
526
|
+
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
527
|
+
method: 'get',
|
|
528
|
+
response: 'json',
|
|
529
|
+
timeout: 15000,
|
|
530
|
+
query: { limit, backendManagerKey: key },
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
return data.sources || [];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const all = [];
|
|
537
|
+
for (const category of categories) {
|
|
538
|
+
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
539
|
+
method: 'get',
|
|
540
|
+
response: 'json',
|
|
541
|
+
timeout: 15000,
|
|
542
|
+
query: { category, limit, backendManagerKey: key },
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
all.push(...(data.sources || []));
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
return all;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Fetch sources for an actual generation run. Either:
|
|
553
|
+
* - A specific source by id — preview only, NO claim (iterate repeatedly on the same source)
|
|
554
|
+
* - Or N per category (claims them atomically for the brand)
|
|
555
|
+
*
|
|
556
|
+
* When NEWSLETTER_SOURCE_ID is set, we look the source up in any status
|
|
557
|
+
* (ready, claimed, used) so you can keep iterating on it across runs without
|
|
558
|
+
* the parent server's claim mechanism marking it consumed.
|
|
559
|
+
*/
|
|
560
|
+
async function fetchSourcesForRun({ parentUrl, newsletterConfig, brandId, sourceId, key }) {
|
|
561
|
+
if (sourceId) {
|
|
562
|
+
// Peek across ALL ready sources (no claim). Search broadly first, then
|
|
563
|
+
// fall back to any-status if needed. We never call claimFor with sourceId
|
|
564
|
+
// so the source stays available for future runs.
|
|
565
|
+
const all = await peekSources({
|
|
566
|
+
parentUrl,
|
|
567
|
+
categories: newsletterConfig.categories || [],
|
|
568
|
+
limit: 100,
|
|
569
|
+
key,
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const match = all.find((s) => s.id === sourceId);
|
|
573
|
+
|
|
574
|
+
if (match) {
|
|
575
|
+
console.log(`[source-id-mode] Found ${sourceId} in ready pool, using WITHOUT claiming (iteration mode)`);
|
|
576
|
+
return [match];
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Not in ready pool — could be already claimed/used. Try the broad endpoint without category filter.
|
|
580
|
+
const broad = await peekSources({
|
|
581
|
+
parentUrl,
|
|
582
|
+
categories: [],
|
|
583
|
+
limit: 100,
|
|
584
|
+
key,
|
|
585
|
+
});
|
|
586
|
+
const matchBroad = broad.find((s) => s.id === sourceId);
|
|
587
|
+
|
|
588
|
+
if (matchBroad) {
|
|
589
|
+
console.log(`[source-id-mode] Found ${sourceId} (outside configured categories), using WITHOUT claiming`);
|
|
590
|
+
return [matchBroad];
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
throw new Error(`Source ${sourceId} not found. It may have been used (status: used) or never existed.`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Normal: claim N per category
|
|
597
|
+
const categories = newsletterConfig.categories || [];
|
|
598
|
+
|
|
599
|
+
if (!categories.length) {
|
|
600
|
+
throw new Error('marketing.beehiiv.content.categories must have at least one entry');
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const all = [];
|
|
604
|
+
for (const category of categories) {
|
|
605
|
+
const data = await fetch(`${parentUrl}/newsletter-sources`, {
|
|
606
|
+
method: 'get',
|
|
607
|
+
response: 'json',
|
|
608
|
+
timeout: 15000,
|
|
609
|
+
query: { category, limit: 3, claimFor: brandId, backendManagerKey: key },
|
|
610
|
+
});
|
|
611
|
+
all.push(...(data.sources || []));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return all;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Find a previous run directory to reuse for theme-only iteration.
|
|
619
|
+
* Either honors NEWSLETTER_REUSE_RUN explicitly, or picks the most recent
|
|
620
|
+
* run-* directory under outRoot. Skips the current in-progress run dir.
|
|
621
|
+
*/
|
|
622
|
+
function resolveReuseRun({ outRoot, explicit }) {
|
|
623
|
+
if (explicit) {
|
|
624
|
+
const candidate = path.join(outRoot, explicit);
|
|
625
|
+
return jetpack.exists(candidate) ? candidate : null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (!jetpack.exists(outRoot)) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const entries = jetpack.list(outRoot) || [];
|
|
633
|
+
const runs = entries
|
|
634
|
+
.filter((name) => name.startsWith('run-'))
|
|
635
|
+
.map((name) => ({ name, full: path.join(outRoot, name) }))
|
|
636
|
+
.filter((r) => {
|
|
637
|
+
// Must contain a structure.json to be reusable
|
|
638
|
+
return jetpack.exists(path.join(r.full, 'structure.json')) === 'file';
|
|
639
|
+
})
|
|
640
|
+
.sort((a, b) => (a.name < b.name ? 1 : -1));
|
|
641
|
+
|
|
642
|
+
return runs[0]?.full || null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function appendClaimed(claimedFile, ids) {
|
|
646
|
+
const existing = jetpack.exists(claimedFile) ? jetpack.read(claimedFile, 'json') || [] : [];
|
|
647
|
+
const stamped = ids.map((id) => ({ id, claimedAt: Math.round(Date.now() / 1000) }));
|
|
648
|
+
jetpack.write(claimedFile, [...existing, ...stamped]);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
async function releaseAll(claimedFile) {
|
|
652
|
+
if (!jetpack.exists(claimedFile)) {
|
|
653
|
+
return 0;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const entries = jetpack.read(claimedFile, 'json') || [];
|
|
657
|
+
|
|
658
|
+
if (!entries.length) {
|
|
659
|
+
return 0;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const saPath = process.env.PARENT_SERVICE_ACCOUNT_PATH;
|
|
663
|
+
|
|
664
|
+
if (!saPath) {
|
|
665
|
+
throw new Error('PARENT_SERVICE_ACCOUNT_PATH env var is required for release. Point it at the parent project service-account.json.');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Lazy-require firebase-admin
|
|
669
|
+
const admin = require('firebase-admin');
|
|
670
|
+
|
|
671
|
+
if (!admin.apps.length) {
|
|
672
|
+
admin.initializeApp({
|
|
673
|
+
credential: admin.credential.cert(require(saPath)),
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const db = admin.firestore();
|
|
678
|
+
let released = 0;
|
|
679
|
+
|
|
680
|
+
for (const entry of entries) {
|
|
681
|
+
try {
|
|
682
|
+
await db.collection('newsletter-sources').doc(entry.id).update({
|
|
683
|
+
status: 'ready',
|
|
684
|
+
usedBy: null,
|
|
685
|
+
claimedBy: null,
|
|
686
|
+
usedAt: null,
|
|
687
|
+
claimedAt: null,
|
|
688
|
+
});
|
|
689
|
+
released++;
|
|
690
|
+
} catch (e) {
|
|
691
|
+
console.warn(`Failed to release ${entry.id}: ${e.message}`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Clear the tracker
|
|
696
|
+
jetpack.write(claimedFile, []);
|
|
697
|
+
|
|
698
|
+
return released;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Minimal Manager stub for tests. Provides what newsletter.js + lib/* read:
|
|
703
|
+
* Manager.config — full config (used for brand, marketing.beehiiv.content, parent)
|
|
704
|
+
* Manager.AI(assistant) — instantiates the unified AI library
|
|
705
|
+
*/
|
|
706
|
+
function buildManagerStub(config) {
|
|
707
|
+
return {
|
|
708
|
+
config,
|
|
709
|
+
libraries: {},
|
|
710
|
+
AI(assistant) {
|
|
711
|
+
const AI = require('../../src/manager/libraries/ai/index.js');
|
|
712
|
+
return new AI(assistant);
|
|
713
|
+
},
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function buildAssistantStub(Manager) {
|
|
718
|
+
return {
|
|
719
|
+
Manager,
|
|
720
|
+
user: null,
|
|
721
|
+
request: { geolocation: { ip: 'local' } },
|
|
722
|
+
log: (...args) => console.log('[ASSIST]', ...args),
|
|
723
|
+
error: (...args) => console.error('[ASSIST ERROR]', ...args),
|
|
724
|
+
getUser: () => null,
|
|
725
|
+
errorify: (msg, opts) => {
|
|
726
|
+
const err = new Error(typeof msg === 'string' ? msg : String(msg));
|
|
727
|
+
if (opts) Object.assign(err, opts);
|
|
728
|
+
return err;
|
|
729
|
+
},
|
|
730
|
+
};
|
|
731
|
+
}
|