backend-manager 5.2.19 → 5.3.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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/CLAUDE.md +5 -4
  3. package/docs/admin-post-route.md +2 -0
  4. package/docs/ai-library.md +29 -2
  5. package/docs/common-mistakes.md +1 -1
  6. package/docs/environment-detection.md +87 -3
  7. package/docs/marketing-campaigns.md +1 -1
  8. package/docs/payment-system.md +1 -1
  9. package/docs/testing.md +60 -15
  10. package/package.json +1 -1
  11. package/src/cli/commands/install.js +2 -2
  12. package/src/cli/commands/setup.js +12 -4
  13. package/src/cli/commands/test.js +1 -1
  14. package/src/cli/index.js +6 -2
  15. package/src/defaults/CLAUDE.md +2 -2
  16. package/src/defaults/test/_init.js +14 -0
  17. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  18. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  19. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  20. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  21. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  22. package/src/manager/helpers/analytics.js +3 -1
  23. package/src/manager/helpers/assistant.js +43 -38
  24. package/src/manager/index.js +76 -12
  25. package/src/manager/libraries/ai/index.js +33 -0
  26. package/src/manager/libraries/ai/providers/openai.js +105 -8
  27. package/src/manager/libraries/content/ghostii.js +76 -16
  28. package/src/manager/libraries/email/data/disposable-domains.json +23 -0
  29. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  30. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  31. package/src/manager/libraries/payment/discount-codes.js +1 -0
  32. package/src/manager/routes/admin/post/put.js +1 -1
  33. package/src/manager/routes/handler/post/post.js +1 -1
  34. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  35. package/src/manager/routes/user/delete.js +1 -1
  36. package/src/manager/routes/user/signup/post.js +4 -2
  37. package/src/test/runner.js +142 -20
  38. package/src/test/test-accounts.js +159 -102
  39. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  40. package/test/events/payments/journey-payments-cancel.js +6 -3
  41. package/test/events/payments/journey-payments-failure.js +6 -3
  42. package/test/events/payments/journey-payments-plan-change.js +6 -3
  43. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  44. package/test/events/payments/journey-payments-suspend.js +6 -3
  45. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  46. package/test/events/payments/journey-payments-trial.js +10 -4
  47. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  48. package/test/events/payments/journey-payments-upgrade.js +6 -3
  49. package/test/helpers/content/ghostii-blocks.js +134 -0
  50. package/test/helpers/environment.js +230 -0
  51. package/test/helpers/webhook-forward.js +26 -0
  52. package/test/routes/marketing/webhook-forward.js +14 -7
  53. package/test/routes/payments/cancel.js +4 -1
  54. package/test/routes/payments/dispute-alert.js +9 -6
  55. package/test/routes/payments/intent.js +55 -14
  56. package/test/routes/payments/portal.js +4 -1
  57. package/test/routes/payments/refund.js +4 -1
@@ -56,11 +56,13 @@ function tryUrl(self) {
56
56
  const host = req.get('host');
57
57
  const forwardedHost = req.get('x-forwarded-host');
58
58
  const path = req.path;
59
- const functionsUrl = Manager.project.functionsUrl;
59
+ const functionsUrl = Manager.getFunctionsUrl();
60
60
 
61
61
  // Like this becuse "req.originalUrl" does NOT have path for all cases (like when calling https://us-central1-{id}.cloudfunctions.net/giftImport)
62
62
  if (projectType === 'firebase') {
63
- if (self.isDevelopment()) {
63
+ // Non-production (development OR testing) reconstructs the URL from the local
64
+ // functions URL; production uses the request host.
65
+ if (!self.isProduction()) {
64
66
  return forwardedHost
65
67
  ? `${protocol}://${forwardedHost}${path}`
66
68
  : `${functionsUrl}/${self.meta.name}`;
@@ -116,6 +118,26 @@ BackendAssistant.prototype.init = function (ref, options) {
116
118
  self.settings = null;
117
119
  self.schema = null;
118
120
 
121
+ // Set ref FIRST so self.Manager is available below — meta.environment forwards to the
122
+ // Manager's canonical getEnvironment() (the Manager is the SSOT), so the Manager ref must
123
+ // be wired before we resolve the environment.
124
+ ref = ref || {};
125
+
126
+ // An assistant has no independent identity — it's a request-scoped face for its Manager,
127
+ // and forwards environment/url resolution to it. A Manager ref is REQUIRED; constructing
128
+ // one without it is a programming error (use Manager.Assistant(), which injects it).
129
+ if (!ref.Manager || typeof ref.Manager.getEnvironment !== 'function') {
130
+ throw new Error('BackendAssistant.init(): a Manager reference is required (ref.Manager). Construct via Manager.Assistant() so it is injected automatically.');
131
+ }
132
+
133
+ self.ref = {};
134
+ self.ref.req = ref.req || {};
135
+ self.ref.res = ref.res || {};
136
+ self.ref.admin = ref.admin || {};
137
+ self.ref.functions = ref.functions || {};
138
+ self.ref.Manager = ref.Manager;
139
+ self.Manager = self.ref.Manager;
140
+
119
141
  // Set meta
120
142
  self.meta = {};
121
143
 
@@ -127,16 +149,6 @@ BackendAssistant.prototype.init = function (ref, options) {
127
149
  self.meta.environment = options.environment || self.getEnvironment();
128
150
  self.meta.type = options.functionType || process.env.FUNCTION_SIGNATURE_TYPE || 'unknown';
129
151
 
130
- // Set ref
131
- self.ref = {};
132
- ref = ref || {};
133
- self.ref.req = ref.req || {};
134
- self.ref.res = ref.res || {};
135
- self.ref.admin = ref.admin || {};
136
- self.ref.functions = ref.functions || {};
137
- self.ref.Manager = ref.Manager || {};
138
- self.Manager = self.ref.Manager;
139
-
140
152
  // Set ID
141
153
  try {
142
154
  const headers = self?.ref?.req.headers || {};
@@ -274,39 +286,31 @@ BackendAssistant.prototype.init = function (ref, options) {
274
286
  return self;
275
287
  };
276
288
 
289
+ // Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (see index.js). The
290
+ // assistant is a request-scoped face for its Manager and FORWARDS these straight through,
291
+ // so request handlers can call `assistant.getEnvironment()` / `assistant.isTesting()` and
292
+ // get exactly the same answer as `Manager.getEnvironment()`. No duplicated env-var logic
293
+ // lives here — there is one implementation, on the Manager. A Manager ref is guaranteed by
294
+ // init() (it throws otherwise), so these never need a fallback.
295
+ //
296
+ // Returns exactly ONE of 'development' | 'testing' | 'production' (mutually exclusive,
297
+ // testing wins). isDevelopment() is NOT true in testing; isProduction() is a real positive
298
+ // check (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
299
+ // or `isDevelopment() || isTesting()` intentionally.
277
300
  BackendAssistant.prototype.getEnvironment = function () {
278
- // return (process.env.FUNCTIONS_EMULATOR === true || process.env.FUNCTIONS_EMULATOR === 'true' || process.env.ENVIRONMENT !== 'production' ? 'development' : 'production')
279
- if (process.env.ENVIRONMENT === 'production') {
280
- return 'production';
281
- } else if (
282
- process.env.ENVIRONMENT === 'development'
283
- || process.env.FUNCTIONS_EMULATOR === true
284
- || process.env.FUNCTIONS_EMULATOR === 'true'
285
- || process.env.TERM_PROGRAM === 'Apple_Terminal'
286
- || process.env.TERM_PROGRAM === 'vscode'
287
- ) {
288
- return 'development';
289
- } else {
290
- return 'production'
291
- }
292
- };
301
+ return this.Manager.getEnvironment();
302
+ }
293
303
 
294
304
  BackendAssistant.prototype.isDevelopment = function () {
295
- const self = this;
296
-
297
- return self.meta.environment === 'development';
305
+ return this.Manager.isDevelopment();
298
306
  }
299
307
 
300
308
  BackendAssistant.prototype.isProduction = function () {
301
- const self = this;
302
-
303
- return self.meta.environment === 'production';
309
+ return this.Manager.isProduction();
304
310
  }
305
311
 
306
312
  BackendAssistant.prototype.isTesting = function () {
307
- const self = this;
308
-
309
- return process.env.BEM_TESTING === 'true';
313
+ return this.Manager.isTesting();
310
314
  }
311
315
 
312
316
  BackendAssistant.prototype.logProd = function () {
@@ -330,10 +334,11 @@ BackendAssistant.prototype._log = function () {
330
334
  if (LOG_LEVELS[level]) {
331
335
  logs.splice(1, 1);
332
336
 
333
- // Determine how to log
337
+ // Determine how to log. Console for any non-production environment (development OR
338
+ // testing); the Firebase Cloud logger only in production.
334
339
  if (level in console) {
335
340
  console[level].apply(console, logs);
336
- } else if (self.isDevelopment()) {
341
+ } else if (!self.isProduction()) {
337
342
  console.log.apply(console, logs);
338
343
  } else {
339
344
  self.ref.functions.logger.write({
@@ -179,6 +179,54 @@ Manager.prototype.init = function (exporter, options) {
179
179
  });
180
180
  }
181
181
 
182
+ // Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (mirrors EM/UJM/BXM,
183
+ // where the Manager owns these). getEnvironment() is the ONLY place that reads the raw
184
+ // env vars (BEM_TESTING / ENVIRONMENT / FUNCTIONS_EMULATOR / TERM_PROGRAM); the three
185
+ // is*() checks derive from it live on every call. They return exactly ONE of three
186
+ // mutually-exclusive values — testing wins, then production, else development.
187
+ // The assistant exposes the same methods but FORWARDS to these (assistant.isTesting()
188
+ // → Manager.isTesting()), so request handlers can keep calling `assistant.*`.
189
+ // Defined BEFORE the assistant is constructed so the assistant's own init() can call back.
190
+ self.getEnvironment = function() {
191
+ // Testing takes precedence — set by the test runner / emulator (BEM_TESTING=true).
192
+ if (process.env.BEM_TESTING === 'true') {
193
+ return 'testing';
194
+ }
195
+ if (process.env.ENVIRONMENT === 'production') {
196
+ return 'production';
197
+ } else if (
198
+ process.env.ENVIRONMENT === 'development'
199
+ || process.env.FUNCTIONS_EMULATOR === true
200
+ || process.env.FUNCTIONS_EMULATOR === 'true'
201
+ || process.env.TERM_PROGRAM === 'Apple_Terminal'
202
+ || process.env.TERM_PROGRAM === 'vscode'
203
+ ) {
204
+ return 'development';
205
+ } else {
206
+ // Default: production. BEM's deployed RUNTIME can legitimately lack a dev signal — a
207
+ // live Cloud Function has no FUNCTIONS_EMULATOR and often no ENVIRONMENT var, so
208
+ // "no signal" IS the normal production state. Defaulting to development here would make
209
+ // every deployed function skip real side effects (emails/analytics/webhooks).
210
+ // (Contrast UJM/BXM, whose deployed artifacts always carry their signal, so they default
211
+ // to development — a bare context there is just build tooling.)
212
+ return 'production';
213
+ }
214
+ };
215
+
216
+ // The three checks are mutually exclusive — each true for ONLY its own environment.
217
+ // isDevelopment() is NOT true in testing; isProduction() is a real positive check
218
+ // (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
219
+ // or `isDevelopment() || isTesting()` intentionally.
220
+ self.isDevelopment = function() {
221
+ return self.getEnvironment() === 'development';
222
+ };
223
+ self.isProduction = function() {
224
+ return self.getEnvironment() === 'production';
225
+ };
226
+ self.isTesting = function() {
227
+ return self.getEnvironment() === 'testing';
228
+ };
229
+
182
230
  // Init assistant
183
231
  self.assistant = self.Assistant().init({
184
232
  req: null,
@@ -190,21 +238,33 @@ Manager.prototype.init = function (exporter, options) {
190
238
 
191
239
  // Helper functions for URLs based on environment
192
240
  self.getFunctionsUrl = function(env) {
193
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
241
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
242
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
243
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
244
+ // the parent is a real remote server with no localhost equivalent.
245
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
194
246
  return isDev
195
247
  ? `http://localhost:5001/${self.project.projectId}/${self.project.resourceZone}`
196
248
  : `https://${self.project.resourceZone}-${self.project.projectId}.cloudfunctions.net`;
197
249
  };
198
250
 
199
251
  self.getApiUrl = function(env) {
200
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
252
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
253
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
254
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
255
+ // the parent is a real remote server with no localhost equivalent.
256
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
201
257
  return isDev
202
258
  ? 'http://localhost:5002'
203
259
  : `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
204
260
  };
205
261
 
206
262
  self.getWebsiteUrl = function(env) {
207
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
263
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
264
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
265
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
266
+ // the parent is a real remote server with no localhost equivalent.
267
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
208
268
  return isDev
209
269
  ? 'https://localhost:4000'
210
270
  : self.config.brand?.url || '';
@@ -254,7 +314,7 @@ Manager.prototype.init = function (exporter, options) {
254
314
  self.project.websiteUrl = self.getWebsiteUrl();
255
315
 
256
316
  // Set environment
257
- process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.assistant.meta.environment;
317
+ process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.getEnvironment();
258
318
 
259
319
  // Set BEM env variables
260
320
  process.env.BEM_FUNCTIONS_URL = self.project.functionsUrl;
@@ -262,9 +322,10 @@ Manager.prototype.init = function (exporter, options) {
262
322
  process.env.BEM_WEBSITE_URL = self.project.websiteUrl;
263
323
 
264
324
  // Use the working Firebase logger that they disabled for whatever reason
325
+ // Load the Firebase logger compat shim only in production (NOT development or testing).
265
326
  if (
266
327
  process.env.GCLOUD_PROJECT
267
- && self.assistant.meta.environment !== 'development'
328
+ && self.isProduction()
268
329
  && options.useFirebaseLogger
269
330
  ) {
270
331
  // require('firebase-functions/lib/logger/compat'); // Old way
@@ -272,7 +333,7 @@ Manager.prototype.init = function (exporter, options) {
272
333
  }
273
334
 
274
335
  // Handle test environment
275
- if (self.assistant.isTesting()) {
336
+ if (self.isTesting()) {
276
337
  self.assistant.log('⚠️⚠️⚠️ Running in TEST environment, some features may be disabled ⚠️⚠️⚠️');
277
338
 
278
339
  // Install the test-mode-file watcher exactly once. Lets the test command
@@ -283,7 +344,7 @@ Manager.prototype.init = function (exporter, options) {
283
344
  }
284
345
 
285
346
  // Handle dev environments
286
- if (self.assistant.isDevelopment()) {
347
+ if (self.isDevelopment()) {
287
348
  const version = require('wonderful-version');
288
349
  const nodeUsing = version.major(process.versions.node);
289
350
  const nodeRequired = version.major(self.package?.engines?.node || '0.0.0');
@@ -331,14 +392,17 @@ Manager.prototype.init = function (exporter, options) {
331
392
  dsn: sentryDSN,
332
393
  release: sentryRelease,
333
394
  beforeSend(event, hint) {
334
- if (self.assistant.isDevelopment() && !self.options.reportErrorsInDev) {
335
- self.assistant.error(new Error('[Sentry] Skipping Sentry because we\'re in development'), hint)
395
+ // Skip Sentry in any non-production environment (development OR testing) unless
396
+ // explicitly opted in. Intentional positive check we never want test-run errors
397
+ // polluting production Sentry.
398
+ if (!self.isProduction() && !self.options.reportErrorsInDev) {
399
+ self.assistant.error(new Error('[Sentry] Skipping Sentry because we\'re not in production'), hint)
336
400
  return null;
337
401
  }
338
402
  event.tags = event.tags || {};
339
403
  event.tags['function.name'] = self.assistant.meta.name;
340
404
  event.tags['function.type'] = self.assistant.meta.type;
341
- event.tags['environment'] = self.assistant.meta.environment;
405
+ event.tags['environment'] = self.getEnvironment();
342
406
  return event;
343
407
  },
344
408
  });
@@ -381,7 +445,7 @@ Manager.prototype.init = function (exporter, options) {
381
445
  }
382
446
 
383
447
  // Fetch stats
384
- if (self.assistant.isDevelopment() && options.fetchStats) {
448
+ if (self.isDevelopment() && options.fetchStats) {
385
449
  setTimeout(function () {
386
450
  self.assistant.log('Fetching meta/stats...');
387
451
  self.libraries.admin
@@ -677,7 +741,7 @@ Manager.prototype.storage = function (options) {
677
741
  // Clear temporary storage
678
742
  if (
679
743
  options.temporary
680
- && self.assistant.isDevelopment()
744
+ && self.isDevelopment()
681
745
  && options.clear
682
746
  ) {
683
747
  self.assistant.log('Removed temporary file @', location);
@@ -92,6 +92,39 @@ AI.prototype.request = async function (options) {
92
92
  return result;
93
93
  };
94
94
 
95
+ /**
96
+ * Generate an image. Dispatches to the provider's image() method.
97
+ *
98
+ * Currently only OpenAI (gpt-image-2) implements image generation.
99
+ *
100
+ * const ai = Manager.AI(assistant);
101
+ * const { buffer } = await ai.image({ prompt: 'a flat vector rocket', size: '1024x1024' });
102
+ *
103
+ * @param {object} options
104
+ * @param {string} options.prompt - Image description (required)
105
+ * @param {'openai'} [options.provider='openai']
106
+ * @param {string} [options.model] - default gpt-image-2
107
+ * @param {string} [options.size] - 1024x1024 | 1536x1024 | 1024x1536 | auto
108
+ * @param {string} [options.quality] - low | medium | high | auto
109
+ * @param {string} [options.background] - transparent | opaque | auto
110
+ * @param {number} [options.n] - how many images (default 1)
111
+ * @param {string} [options.apiKey]
112
+ * @returns {Promise<{buffer, b64, mime, revisedPrompt, model, size, quality, raw}>}
113
+ */
114
+ AI.prototype.image = async function (options) {
115
+ const self = this;
116
+ const opts = options || {};
117
+ const provider = opts.provider || DEFAULT_PROVIDER;
118
+
119
+ const client = self._getProvider(provider, opts.apiKey);
120
+
121
+ if (typeof client.image !== 'function') {
122
+ throw new Error(`AI provider "${provider}" does not support image generation`);
123
+ }
124
+
125
+ return client.image(opts);
126
+ };
127
+
95
128
  AI.prototype._getProvider = function (provider, apiKey) {
96
129
  const self = this;
97
130
 
@@ -9,6 +9,7 @@ const mimeTypes = require('mime-types');
9
9
  // Constants
10
10
  const DEFAULT_MODEL = 'gpt-5.4-mini';
11
11
  const MODERATION_MODEL = 'omni-moderation-latest';
12
+ const IMAGE_DEFAULT_MODEL = 'gpt-image-2';
12
13
 
13
14
  // OpenAI model pricing table (per 1M tokens)
14
15
  // https://platform.openai.com/docs/pricing
@@ -395,10 +396,12 @@ OpenAI.prototype.request = function (options) {
395
396
  // Reasons
396
397
  options.reasoning = options.reasoning || undefined;
397
398
 
398
- // Tools (e.g. built-in web_search). Opt-in when omitted, no tools are sent
399
- // and behavior is identical to a plain request.
399
+ // Tools nested, opt-in. `tools.list` is an array of tool definitions (built-in
400
+ // hosted tools like `{ type: 'web_search' }`, OR custom function tools like
401
+ // `{ type: 'function', name, parameters }`); `tools.choice` maps to `tool_choice`
402
+ // (`auto` | `required` | `none` | a specific tool). When omitted/empty, no tools
403
+ // are sent and behavior is identical to a plain request.
400
404
  options.tools = options.tools || undefined;
401
- options.toolChoice = options.toolChoice || undefined;
402
405
 
403
406
  // Format prompt
404
407
  //
@@ -512,6 +515,100 @@ OpenAI.prototype.request = function (options) {
512
515
  });
513
516
  }
514
517
 
518
+ /**
519
+ * Generate an image via OpenAI's image API (gpt-image-2 by default).
520
+ *
521
+ * Returns the raw bytes — caller decides what to do with them (upload to a CDN,
522
+ * embed as a data URL, write to disk, etc.). gpt-image-* always returns base64,
523
+ * so there's no URL-fetch round trip.
524
+ *
525
+ * @param {object} options
526
+ * @param {string} options.prompt - The image description (required)
527
+ * @param {string} [options.model] - Image model (default gpt-image-2)
528
+ * @param {string} [options.size] - 1024x1024 | 1536x1024 | 1024x1536 | auto (default 1024x1024)
529
+ * @param {string} [options.quality] - low | medium | high | auto (default medium)
530
+ * @param {string} [options.background] - transparent | opaque | auto
531
+ * @param {number} [options.n] - How many images (default 1)
532
+ * @param {number} [options.timeout] - ms (default 300000 — image gen is slow)
533
+ * @param {boolean}[options.log]
534
+ * @returns {Promise<{buffer: Buffer, b64: string, mime: string, revisedPrompt: string|null, model: string, size: string, quality: string, raw: object}>}
535
+ * (or an array of those when n > 1)
536
+ */
537
+ OpenAI.prototype.image = function (options) {
538
+ const self = this;
539
+ const assistant = self.assistant;
540
+
541
+ return new Promise(async function (resolve, reject) {
542
+ options = _.merge({}, options);
543
+
544
+ if (!options.prompt) {
545
+ return reject(assistant.errorify(`image(): {prompt} is required`, { code: 400 }));
546
+ }
547
+
548
+ options.model = options.model || IMAGE_DEFAULT_MODEL;
549
+ options.size = options.size || '1024x1024';
550
+ options.quality = options.quality || 'medium';
551
+ options.n = options.n || 1;
552
+ options.timeout = typeof options.timeout === 'undefined' ? 300000 : options.timeout;
553
+ options.log = typeof options.log === 'undefined' ? false : options.log;
554
+
555
+ const body = {
556
+ model: options.model,
557
+ prompt: options.prompt,
558
+ size: options.size,
559
+ quality: options.quality,
560
+ n: options.n,
561
+ };
562
+
563
+ // background is opt-in (transparent/opaque/auto)
564
+ if (options.background) {
565
+ body.background = options.background;
566
+ }
567
+
568
+ if (options.log) {
569
+ assistant.log(`OpenAI.image(): model=${options.model} size=${options.size} quality=${options.quality} n=${options.n}`);
570
+ }
571
+
572
+ let data;
573
+ try {
574
+ data = await fetch('https://api.openai.com/v1/images/generations', {
575
+ method: 'post',
576
+ response: 'json',
577
+ timeout: options.timeout,
578
+ tries: options.tries || 1,
579
+ headers: {
580
+ 'Authorization': `Bearer ${self.key}`,
581
+ 'Content-Type': 'application/json',
582
+ },
583
+ body: body,
584
+ });
585
+ } catch (e) {
586
+ return reject(assistant.errorify(`OpenAI.image() request failed: ${e.message}`, { code: e.code || 500 }));
587
+ }
588
+
589
+ if (!data?.data?.length) {
590
+ return reject(assistant.errorify(`OpenAI.image() returned no image data: ${JSON.stringify(data).slice(0, 300)}`, { code: 500 }));
591
+ }
592
+
593
+ const mapItem = (item) => {
594
+ const b64 = item.b64_json;
595
+ return {
596
+ buffer: b64 ? Buffer.from(b64, 'base64') : null,
597
+ b64: b64 || null,
598
+ mime: 'image/png',
599
+ revisedPrompt: item.revised_prompt || null,
600
+ model: options.model,
601
+ size: options.size,
602
+ quality: options.quality,
603
+ raw: data,
604
+ };
605
+ };
606
+
607
+ // Single image (the common case) returns one object; n > 1 returns an array.
608
+ return resolve(options.n === 1 ? mapItem(data.data[0]) : data.data.map(mapItem));
609
+ });
610
+ };
611
+
515
612
  function tryParse(content) {
516
613
  try {
517
614
  return JSON5.parse(content);
@@ -983,15 +1080,15 @@ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
983
1080
  request.body.reasoning = reasoning;
984
1081
  }
985
1082
 
986
- // Only include tools (e.g. web_search) if provided. When present, the
1083
+ // Only include tools if `tools.list` is a non-empty array. When present, the
987
1084
  // response output may contain tool-call items (e.g. web_search_call)
988
1085
  // alongside the message — the message extractor below already ignores
989
1086
  // non-message items, so this is purely additive.
990
- if (Array.isArray(options.tools) && options.tools.length) {
991
- request.body.tools = options.tools;
1087
+ if (Array.isArray(options.tools?.list) && options.tools.list.length) {
1088
+ request.body.tools = options.tools.list;
992
1089
 
993
- if (options.toolChoice) {
994
- request.body.tool_choice = options.toolChoice;
1090
+ if (options.tools.choice) {
1091
+ request.body.tool_choice = options.tools.choice;
995
1092
  }
996
1093
  }
997
1094
  }
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * Ghostii article engine — shared helpers for AI article generation + publishing.
3
3
  *
4
- * Ghostii (api.ghostii.ai) writes a full blog article (title, body, header image,
5
- * categories, keywords) from a free-form description. We then publish it to the
6
- * brand's website repo via the internal `admin/post` route (commits markdown +
7
- * images to GitHub).
4
+ * Ghostii (api.ghostii.ai) is UNOPINIONATED about BEM. It returns a generic,
5
+ * structured article: a `json` block array ([{ name, content }]) plus `title`,
6
+ * `description`, `headerImageUrl`, `categories`, `keywords`. It is NOT shaped to
7
+ * BEM's post format — BEM is responsible for transforming Ghostii's output into
8
+ * what its `admin/post` route expects.
9
+ *
10
+ * `blocksToPost()` is that transform: it digests the JSON blocks and pulls out
11
+ * the title (heading-1), the header image (first image block), and the clean
12
+ * body (every remaining block — sections, paragraphs, section images, quotes —
13
+ * joined as markdown, with NO title or header image embedded, since admin/post
14
+ * adds those itself from the separate `title`/`headerImageURL` fields).
8
15
  *
9
16
  * Two consumers:
10
17
  * - events/cron/daily/ghostii-auto-publisher.js — standalone daily article job
@@ -23,12 +30,15 @@ const powertools = require('node-powertools');
23
30
  * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { ... } })
24
31
  * @param {string} args.description - The article brief / prompt content
25
32
  * @param {string[]} [args.links] - Optional links to inject into the article body
26
- * @returns {Promise<object>} { title, description, body, headerImageUrl, categories, keywords }
33
+ * @returns {Promise<object>} Ghostii's generic article response:
34
+ * { title, description, body, json, headerImageUrl, images, categories, keywords, links, outline }
35
+ * where `json` is the structured block array ([{ name, content }]) that
36
+ * blocksToPost() consumes to build BEM's post shape.
27
37
  */
28
38
  function writeArticle({ brand, description, links }) {
29
39
  return fetch('https://api.ghostii.ai/write/article', {
30
40
  method: 'post',
31
- timeout: 90000,
41
+ timeout: 180000,
32
42
  tries: 1,
33
43
  response: 'json',
34
44
  body: {
@@ -36,6 +46,10 @@ function writeArticle({ brand, description, links }) {
36
46
  keywords: [''],
37
47
  description: description,
38
48
  insertLinks: true,
49
+ research: true,
50
+ insertImages: true,
51
+ length: 'long',
52
+ maxLinks: 6,
39
53
  headerImageUrl: 'unsplash',
40
54
  url: brand.brand.url,
41
55
  sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
@@ -45,13 +59,51 @@ function writeArticle({ brand, description, links }) {
45
59
  });
46
60
  }
47
61
 
62
+ /**
63
+ * Transform Ghostii's generic JSON block array into BEM's post shape.
64
+ *
65
+ * Ghostii returns `json: [{ name, content }]` where name ∈ heading-1..6, image,
66
+ * paragraph, blockquote, list. BEM's admin/post wants the title + header image as
67
+ * SEPARATE fields and a body that is ONLY the content below them. So we extract:
68
+ * - title ← first heading-1 block (markdown `#` stripped)
69
+ * - headerImageUrl ← first image block's URL
70
+ * - body ← every remaining block, joined as markdown
71
+ *
72
+ * @param {Array} json - Ghostii's block array.
73
+ * @returns {{ title: string, headerImageUrl: string, body: string }}
74
+ */
75
+ function blocksToPost(json) {
76
+ const blocks = Array.isArray(json) ? json : [];
77
+
78
+ const titleBlock = blocks.find((b) => b.name === 'heading-1');
79
+ const imageBlock = blocks.find((b) => b.name === 'image');
80
+
81
+ // Title: strip the leading markdown heading marker.
82
+ const title = (titleBlock?.content || '').replace(/^#+\s*/, '').trim();
83
+
84
+ // Header image: pull the URL out of `![alt](url)`.
85
+ const imageMatch = (imageBlock?.content || '').match(/\((.*?)\)\s*$/);
86
+ const headerImageUrl = imageMatch ? imageMatch[1] : '';
87
+
88
+ // Body: everything except the title block and the header-image block.
89
+ const body = blocks
90
+ .filter((b) => b !== titleBlock && b !== imageBlock)
91
+ .map((b) => b.content)
92
+ .join('\n\n')
93
+ .trim();
94
+
95
+ return { title, headerImageUrl, body };
96
+ }
97
+
48
98
  /**
49
99
  * Publish a Ghostii article to the brand's website repo via the admin/post route.
50
100
  *
51
101
  * @param {object} assistant - BEM assistant instance
52
102
  * @param {object} args
53
103
  * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { user, repo } })
54
- * @param {object} args.article - The article from writeArticle()
104
+ * @param {object} args.article - The article from writeArticle(). When it carries a
105
+ * `json` block array, the post is reconstructed via blocksToPost() (title + header
106
+ * image extracted, body = content only). Falls back to article.{title,body,headerImageUrl}.
55
107
  * @param {number} args.id - Post ID (unix timestamp)
56
108
  * @param {string} [args.author] - Author slug (admin/post picks a default if unset)
57
109
  * @param {string} [args.postPath='ghostii'] - Sub-folder under _posts/{year}/
@@ -61,18 +113,26 @@ async function publishArticle(assistant, { brand, article, id, author, postPath
61
113
  const baseUrl = (brand.brand.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
62
114
  const apiUrl = `https://api.${baseUrl}`;
63
115
 
64
- const post = await fetch(`${apiUrl}/backend-manager/admin/post`, {
116
+ // Transform Ghostii's generic JSON into BEM's post shape (title + header image
117
+ // as separate fields, body = content only). Fall back to the legacy flat fields
118
+ // for older Ghostii responses that don't include `json`.
119
+ const post = blocksToPost(article.json);
120
+ const title = post.title || article.title;
121
+ const headerImageUrl = post.headerImageUrl || article.headerImageUrl;
122
+ const body = article.json ? post.body : article.body;
123
+
124
+ const result = await fetch(`${apiUrl}/backend-manager/admin/post`, {
65
125
  method: 'POST',
66
126
  timeout: 90000,
67
127
  tries: 1,
68
128
  response: 'json',
69
129
  body: {
70
130
  backendManagerKey: process.env.BACKEND_MANAGER_KEY,
71
- title: article.title,
72
- url: article.title, // Slugified on the admin/post endpoint
131
+ title: title,
132
+ url: title, // Slugified on the admin/post endpoint
73
133
  description: article.description,
74
- headerImageURL: article.headerImageUrl,
75
- body: article.body,
134
+ headerImageURL: headerImageUrl,
135
+ body: body,
76
136
  id: id,
77
137
  author: author,
78
138
  categories: article.categories,
@@ -86,15 +146,15 @@ async function publishArticle(assistant, { brand, article, id, author, postPath
86
146
  // admin/post returns the resolved `settings` (incl. the slugified `url` and repo `path`).
87
147
  // The post template sets no permalink, so the public URL follows the Jekyll/UJM
88
148
  // blog convention: {brand.url}/blog/{slug}.
89
- const slug = post?.url || '';
149
+ const slug = result?.url || '';
90
150
  const publicUrl = `${(brand.brand.url || '').replace(/\/$/, '')}/blog/${slug}`;
91
151
 
92
152
  return {
93
- post,
153
+ post: result,
94
154
  url: slug ? publicUrl : null,
95
155
  slug,
96
- path: post?.path || null,
156
+ path: result?.path || null,
97
157
  };
98
158
  }
99
159
 
100
- module.exports = { writeArticle, publishArticle };
160
+ module.exports = { writeArticle, publishArticle, blocksToPost };