backend-manager 5.2.15 → 5.2.17

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 CHANGED
@@ -14,6 +14,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
14
14
  - `Fixed` for any bug fixes.
15
15
  - `Security` in case of vulnerabilities.
16
16
 
17
+ # [5.2.17] - 2026-05-29
18
+
19
+ ### Added
20
+
21
+ - **Newsletter-driven blog articles (`marketing.beehiiv.content.article`).** When enabled, the newsletter generator expands its **lead section** (`structure.sections[0]`) into a full blog article via the Ghostii engine, publishes it to the website repo through the `admin/post` route, and injects a "Read the full article" CTA (`section.cta = { label, url }`) onto that section so the newsletter links to the long-form post. The article build runs **concurrently** with SVG image generation (both are slow AI calls) and is **failure-isolated** — if it throws, no CTA is injected and the newsletter ships normally. The published URL (`{brand.url}/blog/{slug}`) is surfaced on the generator return as `assets.articleUrl` and `meta.article`. New config block: `content.article = { enabled, author }` (`enabled` default `false`). See `docs/marketing-campaigns.md`.
22
+ - **`section.cta` rendering.** Both the MJML template (`sectionCard`, already supported) and the markdown renderer (`lib/markdown-renderer.js`, new) now render `section.cta = { label, url }` when present. `getBodySections` passes `cta` through for both classic and field-report shapes. CTAs are still never authored by the AI — they're injected by code post-publish with a real, verified URL.
23
+ - **Generate/publish split on the linked article (`opts.publishArticle`).** When `config.article.enabled` is on, the article is always **generated** (Ghostii writes it + the public URL is computed from the title slug + the CTA is injected), but it's only **committed** to the website repo via `admin/post` when `opts.publishArticle` is true. The production cron passes `publishArticle: true`. The newsletter iteration test (`test/marketing/newsletter-generate.js`) leaves it false by default — so a full `TEST_EXTENDED_MODE=1` run exercises the Ghostii write + CTA path without committing a real post — and opts in with `NEWSLETTER_CREATE_ARTICLE=1`. The CTA URL is valid either way (derived from the same slugify `admin/post` uses). `meta.article.published` records whether the post was actually committed.
24
+
25
+ ### Changed
26
+
27
+ - **Extracted the Ghostii article engine into `src/manager/libraries/content/ghostii.js`** (`writeArticle` + `publishArticle`) as the SSOT for the Ghostii API request shape and the `admin/post` publish payload. Both the standalone `ghostii-auto-publisher.js` daily cron and the new newsletter linked-article flow import it (DRY). No behavior change to the standalone cron.
28
+ - **Standalone Ghostii is now disabled by default** (`ghostii[0].articles: 0` in `templates/backend-manager-config.json`). The daily `ghostii-auto-publisher.js` cron remains fully functional — set `articles >= 1` to opt back into independent article publishing. For newsletter-linked articles, use `content.article.enabled` instead.
29
+
30
+ # [5.2.16] - 2026-05-28
31
+
32
+ ### Removed
33
+
34
+ - **Dropped the legacy top-level `affiliateCode` field from `/user/signup`.** UJM and all current consumers send the referral code as `attribution.affiliate.code`; the top-level `affiliateCode` (and the normalize-to-`attribution` shim + the `processAffiliate` fallback that read it) was a dead legacy path. Removed from `src/manager/schemas/user/signup/post.js`, the `buildUserRecord` normalize block, and the `processAffiliate` lookup in `src/manager/routes/user/signup/post.js`. The route now reads referral codes exclusively from `attribution.affiliate.code`. (The legacy `bm_api` sign-up action `functions/core/actions/api/user/sign-up.js` is unchanged.)
35
+
36
+ ### Fixed
37
+
38
+ - **Consent: never downgrade an existing granted consent on `/user/signup`** (`src/manager/routes/user/signup/post.js`). A legacy account — signed up before the `flags.signupProcessed` completion flow existed, so the flag was never set — re-fires `/user/signup` on every page load until the flag flips. Its consent payload arrives empty (the original is long gone from `localStorage`), which previously computed `'revoked'` and, on the `{ merge: true }` write, wiped the consent the user actually granted months ago. `buildConsentRecord` now reads the existing doc's consent and preserves any already-`granted` status when the incoming payload doesn't explicitly re-grant it. A genuine new grant still applies; an at-signup decline with no prior grant still records the decline. Added `consent-empty-payload-preserves-existing-grant` and `consent-explicit-decline-does-not-downgrade-existing-grant` tests (+ a dedicated `consent-preserve` test account). See `docs/consent.md`.
39
+
40
+ ### Changed
41
+
42
+ - **`user/signup` schema: shaped the `consent` field.** `src/manager/schemas/user/signup/post.js` now declares the nested `consent.{legal,marketing}.{granted,text}` shape instead of a bare passthrough object, documenting the input contract at the schema layer (the SSOT for request shape). Each sub-object is optional — omitting it leaves existing consent untouched (see the downgrade guard above).
43
+
17
44
  # [5.2.15] - 2026-05-28
18
45
 
19
46
  ### Changed
package/CLAUDE.md CHANGED
@@ -34,7 +34,7 @@ All `npx mgr <cmd>` aliases work: `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-
34
34
  1. `npm install` — install BEM's own deps
35
35
  2. `npm run prepare` — build once: copies `src/` → `dist/` via prepare-package
36
36
  3. `npm run prepare:watch` — watch mode
37
- 4. Test in a consumer project: from inside the consumer's `functions/` dir, run `npx mgr install local` (swaps BEM to the local repo via the `install` CLI). Reverse with `npx mgr install prod`.
37
+ 4. Test in a consumer project: from inside the consumer, run `npx mgr install dev` to swap BEM to this local repo required whenever you edit the framework source and want the consumer to pick up the changes (the consumer otherwise keeps its installed `node_modules/backend-manager`). Reverse with `npx mgr install prod`. If `npx mgr` then errors with "could not determine executable to run", the local install skipped bin-linking — re-run `npm install` to relink, or call `node node_modules/backend-manager/bin/backend-manager <cmd>` directly.
38
38
 
39
39
  ## Architecture
40
40
 
@@ -119,9 +119,9 @@ Deep references live in `docs/`. **Whenever you make a behavioral change, update
119
119
 
120
120
  ### Built-in Routes
121
121
 
122
- - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting)
122
+ - [docs/admin-post-route.md](docs/admin-post-route.md) — `POST/PUT /admin/post` blog creation via GitHub (image extraction + resize at ingest + `@post/` rewriting). Also the publish target for the Ghostii article engine (`libraries/content/ghostii.js`).
123
123
  - [docs/payment-system.md](docs/payment-system.md) — full payment pipeline: Intent → Webhook → On-Write → Transition; subscription model, statuses, `resolveSubscription()`, transition handlers, processor interface, product config, test processor
124
- - [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), template-owned schemas, asset hosting, seed campaigns
124
+ - [docs/marketing-campaigns.md](docs/marketing-campaigns.md) — campaign CRUD routes, recurring campaigns, generator pipeline (newsletter), newsletter-driven blog article (`content.article.enabled`), template-owned schemas, asset hosting, seed campaigns
125
125
  - [docs/consent.md](docs/consent.md) — marketing consent capture: canonical `consent.{legal,marketing}` user-doc shape, signup-form capture, account-page toggle, HMAC unsub link, SendGrid+Beehiiv webhook receivers, parent forwarder (`/marketing/webhook/forward`), migration script template
126
126
  - [docs/mcp.md](docs/mcp.md) — Model Context Protocol server: 19 tools, stdio + HTTP transports, OAuth, Claude Chat/Code configuration
127
127
 
package/README.md CHANGED
@@ -412,6 +412,7 @@ Built-in marketing system with multi-provider support (SendGrid + Beehiiv + FCM
412
412
  - **Campaign CRUD** — `POST/GET/PUT/DELETE /marketing/campaign` with calendar-backed scheduling
413
413
  - **Recurring campaigns** — seasonal sales, newsletters with automatic sendAt advancement
414
414
  - **Newsletter generator** — AI-assembled newsletters from parent server content sources
415
+ - **Newsletter-driven blog articles** — `content.article.enabled` expands the newsletter's lead section into a full blog post (via Ghostii → `admin/post`) and links to it with a "Read the full article" CTA
415
416
  - **Segment SSOT** — 22 segment definitions resolved to provider IDs at runtime
416
417
  - **UTM auto-tagging** — brand domain links tagged automatically in marketing + transactional emails
417
418
  - **Contact pruning** — monthly 2-stage re-engagement + deletion of inactive contacts
@@ -2,6 +2,8 @@
2
2
 
3
3
  The `POST /admin/post` route creates blog posts via GitHub's API. It handles image extraction, resize, upload, and body rewriting.
4
4
 
5
+ **Consumers.** Besides direct admin/blogger HTTP calls, this route is the publish target for the **Ghostii article engine** (`src/manager/libraries/content/ghostii.js` → `publishArticle()`), which is used by two paths: the standalone daily `ghostii-auto-publisher.js` cron (off by default), and the newsletter generator's linked-article flow (`marketing.beehiiv.content.article.enabled`). Both POST `title`/`url`/`description`/`headerImageURL`/`body`/`author`/`categories`/`tags`/`postPath`/`githubUser`/`githubRepo` with a `backendManagerKey`. Note: `headerImageURL` MUST resolve to a `.jpg` (the downloader rejects non-`.jpg` headers); Ghostii's `unsplash` hero satisfies this. See [docs/marketing-campaigns.md](marketing-campaigns.md).
6
+
5
7
  ## Image Processing Flow
6
8
 
7
9
  1. Receives markdown body with external image URLs (e.g., `![alt](https://images.unsplash.com/...)`)
@@ -14,6 +14,23 @@ Return shape (same for all providers): `{ content, output, tokens, raw }`.
14
14
 
15
15
  API keys: `BACKEND_MANAGER_OPENAI_API_KEY`, `BACKEND_MANAGER_ANTHROPIC_API_KEY` (process.env or config).
16
16
 
17
+ ## Tools / web search (OpenAI)
18
+
19
+ `options.tools` (and optional `options.toolChoice`) are passed through to the OpenAI Responses API verbatim. Opt-in — when omitted, no tools are sent and behavior is identical to a plain request. Use this to enable OpenAI's built-in **web search** so the model finds and cites real, currently-live URLs instead of hallucinating them:
20
+
21
+ ```js
22
+ const r = await ai.request({
23
+ model: 'gpt-5.4',
24
+ response: 'json',
25
+ reasoning: { effort: 'medium' },
26
+ tools: [{ type: 'web_search' }],
27
+ prompt: { path: '.../research/system.md', settings },
28
+ message: { path: '.../research/user.md', settings },
29
+ });
30
+ ```
31
+
32
+ When tools are active, the response `output` array may contain tool-call items (e.g. `web_search_call`) alongside the `message`; the message-text extractor ignores non-message items, so `r.content` is unaffected. URL citations live in the returned `output` (message content) as `annotations` of type `url_citation`.
33
+
17
34
  ## `claude-code` provider — subscription billing
18
35
 
19
36
  The `claude-code` provider hits the same Claude Messages API as `anthropic`, but authenticates with a **Claude Code OAuth token** (`Authorization: Bearer ...` + `anthropic-beta: oauth-2025-04-20`) so usage bills the Claude Pro/Max subscription tied to the token rather than API credits.
package/docs/consent.md CHANGED
@@ -67,16 +67,19 @@ There are four places where consent gets recorded or updated. All four converge
67
67
  }
68
68
  ```
69
69
 
70
- `buildConsentRecord(assistant, settings.consent)` translates this into the canonical user-doc shape:
70
+ `buildConsentRecord(assistant, settings.consent, creationTime, existingConsent)` translates this into the canonical user-doc shape:
71
71
 
72
- - `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **server timestamp** + server-detected IP + exact label text.
72
+ - `legal.granted: true` → `legal.status = 'granted'`, `grantedAt` populated with `source: 'signup'` + **timestamp from Auth `creationTime`** + server-detected IP + exact label text.
73
73
  - `marketing.granted: false` → `marketing.status = 'revoked'`, `grantedAt` all-null, `revokedAt` populated with `source: 'signup'`. (Records the explicit decline.)
74
- - Missing `consent` block (legacy client) → both default to `'revoked'`.
75
74
 
76
- **Server time is authoritative.** Client-supplied timestamps are ignored defends against clock manipulation by malicious clients.
75
+ **Timestamps come from Firebase Auth `creationTime`,** not request time, so `consent.grantedAt` matches `metadata.created` (the OMEGA user migration treats `metadata.created` as the SSOT and reconciles `grantedAt` against it stamping from request time made every new signup drift by a few seconds and get re-fixed on the next migration run).
76
+
77
+ **Server-derived time is authoritative.** Client-supplied timestamps are ignored — defends against clock manipulation by malicious clients.
77
78
 
78
79
  **Strict boolean check.** Only `granted === true` counts as granted. `'true'`, `1`, or other truthy values are rejected.
79
80
 
81
+ **Never downgrades an existing grant (data-loss guard).** A legacy account — one signed up before the `flags.signupProcessed` completion flow existed, so its flag was never set — re-fires `/user/signup` on every page load until the flag flips. Its consent was captured months ago and is long gone from `localStorage`, so the payload arrives empty. Without protection, `buildConsentRecord` would compute `'revoked'` and the `{ merge: true }` write would wipe the consent the user actually granted. The guard reads the existing doc's consent (`existingConsent`) and **preserves any already-`granted` status when the incoming payload does not explicitly re-grant it.** A genuine new grant still applies; an at-signup decline with no prior grant still records the decline. (The primary mitigation is OMEGA migration Fix 4f, which backfills `flags.signupProcessed: true` for established accounts so they never re-fire; this guard is the backstop for the deploy-before-migration gap.)
82
+
80
83
  **Marketing sync gating.** After writing the user doc, the route checks `userRecord.consent.marketing.status === 'granted'` before calling `mailer.sync(uid)`. Declining the marketing checkbox means the user is created normally, gets transactional emails, but is NEVER added to SendGrid / Beehiiv marketing lists.
81
84
 
82
85
  ### 2. Account-page toggle (Phase D)
@@ -100,6 +100,22 @@ Pipeline:
100
100
  4. **mjml-template.js** — Resolves the template by name from `templates/index.js`, calls `template.build({structure, imagePaths, theme, ...})` for the MJML, compiles to email-safe HTML via the `mjml` package. Brand-domain links get UTM-tagged via the existing `tagLinks()` utility.
101
101
  5. Mark used: `PUT {parentUrl}/newsletter/sources` per source
102
102
 
103
+ > **Step 3 runs concurrently with the linked-article build** (see below) when `article.enabled` is on — both are slow AI calls, so they share one `Promise.all`. The article URL is stamped onto the lead section before steps 4/5 render.
104
+
105
+ ## Newsletter-driven blog article (`article.enabled`)
106
+
107
+ When `marketing.beehiiv.content.article.enabled: true`, the newsletter generator expands its **lead section** (`structure.sections[0]`) into a full blog article and links to it from the newsletter.
108
+
109
+ Flow (runs in parallel with SVG generation, between structure and render):
110
+ 1. `buildLinkedArticle()` builds a brief from the lead section's title + body, folded with the shared `tone` + `instructions`.
111
+ 2. **Ghostii** (`libraries/content/ghostii.js` → `writeArticle()`) expands it into a full article + hero image.
112
+ 3. `publishArticle()` POSTs to the `admin/post` route (commits markdown + images to the website repo). Public URL = `{brand.url}/blog/{slug}`.
113
+ 4. The URL is injected as `structure.sections[0].cta = { label: 'Read the full article', url }`. The MJML `sectionCard` and the markdown renderer both render `section.cta` automatically.
114
+
115
+ **Failure is isolated** — if the article build throws, it resolves to `null`, no CTA is injected, and the newsletter ships normally. The newsletter never depends on the article succeeding.
116
+
117
+ The published URL is surfaced on the return as `assets.articleUrl` and `meta.article`. Config block: `marketing.beehiiv.content.article = { enabled, author }` (`enabled` default `false`; `author` is the post author slug). Standalone article publishing (independent of the newsletter) still lives in the daily `ghostii-auto-publisher.js` cron, which now shares the same `libraries/content/ghostii.js` engine — see [docs/admin-post-route.md](admin-post-route.md). Standalone Ghostii is **disabled by default** (`ghostii[0].articles: 0`).
118
+
103
119
  ## Template-owned schemas
104
120
 
105
121
  Each template under `lib/templates/` owns its own content shape. Templates export:
@@ -199,7 +215,7 @@ marketing-campaigns/{newId}: {
199
215
 
200
216
  Templates add their own fields on top (e.g. classic adds `intro` + `sections`; field-report adds `tldr` + `dateline` + `dispatches`).
201
217
 
202
- **No CTAs in generated content.** The schema intentionally does NOT include section-level CTAs / outbound links. The AI cannot author URLs reliably — it has no browse access to your site and no real source URLs to reference, so any URL it produces is invented. Newsletters are self-contained reads; outbound links come from sponsorship blocks rendered by the template shell (driven by `marketing.beehiiv.content.sponsorships[]`), not from generated section bodies.
218
+ **No AI-authored CTAs in generated content.** The schema intentionally does NOT include section-level CTAs / outbound links. The AI cannot author URLs reliably — it has no browse access to your site and no real source URLs to reference, so any URL it produces is invented. Newsletters are self-contained reads; outbound links come from sponsorship blocks rendered by the template shell (driven by `marketing.beehiiv.content.sponsorships[]`), not from generated section bodies. The **one exception** is the linked-article CTA: when `article.enabled` is on, `section.cta = { label, url }` is injected onto the lead section by code *after* the article is published (a real, verified URL) — never authored by the AI. Both `sectionCard` (MJML) and the markdown renderer render `section.cta` when present.
203
219
 
204
220
  **Beehiiv failure → fallback alert email.** When Beehiiv draft creation fails (e.g. `SEND_API_NOT_ENTERPRISE_PLAN` on the free plan), the generator sends an internal alert email via `sender: 'internal'` (resolves to `alerts@{brandDomain}`) to `brand.contact.email` with:
205
221
  - The failure reason
@@ -249,6 +265,10 @@ marketing: {
249
265
  instructions: '', // free-form AI instructions
250
266
  tone: 'professional',
251
267
  template: 'clean', // clean | editorial | field-report
268
+ article: { // expand the lead section into a linked blog post (Ghostii → admin/post)
269
+ enabled: false,
270
+ author: 'alex-raeburn', // author slug for the linked article
271
+ },
252
272
  theme: { primaryColor, secondaryColor, accentColor, font },
253
273
  sponsorships: [ ... ],
254
274
  },
@@ -270,6 +290,8 @@ marketing: {
270
290
  | Newsletter MJML → HTML | `src/manager/libraries/email/generators/lib/mjml-template.js` |
271
291
  | Newsletter asset host (GitHub upload — PNGs + newsletter.html + newsletter.md + summary.md) | `src/manager/libraries/email/generators/lib/image-host.js` |
272
292
  | Newsletter markdown renderer (programmatic, no AI) | `src/manager/libraries/email/generators/lib/markdown-renderer.js` |
293
+ | Ghostii article engine (writeArticle + publishArticle) | `src/manager/libraries/content/ghostii.js` |
294
+ | Standalone Ghostii article cron (off by default) | `src/manager/events/cron/daily/ghostii-auto-publisher.js` |
273
295
  | Unified AI library | `src/manager/libraries/ai/index.js` (OpenAI + Anthropic via `Manager.AI(assistant).request({ provider, ... })`) |
274
296
  | Notification library | `src/manager/libraries/notification.js` |
275
297
  | SendGrid provider | `src/manager/libraries/email/providers/sendgrid.js` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.2.15",
3
+ "version": "5.2.17",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -130,6 +130,14 @@ class BaseCommand {
130
130
  }
131
131
  }
132
132
 
133
+ // Non-interactive environments (CI, agents, piped stdin) have no TTY, so inquirer
134
+ // can't read a keypress — prompting would error or hang. Skip the prompt entirely
135
+ // and auto-confirm the kill so unattended `mgr test` / `mgr emulator` runs proceed.
136
+ if (!process.stdin.isTTY) {
137
+ this.log(chalk.gray(' Non-interactive shell — auto-confirming port cleanup (Y).'));
138
+ return this.killBlockingProcesses(blockedPorts);
139
+ }
140
+
133
141
  // Auto-confirm (Y) after a few seconds of no input so unattended test/dev loops don't
134
142
  // hang. When the timeout fires the prompt is aborted via AbortSignal and we fall back to
135
143
  // the default (true). inquirer owns the cursor and can't live-update its own message, so
@@ -142,11 +150,20 @@ class BaseCommand {
142
150
  { signal: AbortSignal.timeout(AUTO_CONFIRM_SECONDS * 1000) },
143
151
  );
144
152
  } catch (error) {
145
- // AbortPromptError (timeout reached)take the default (true). Re-throw anything else.
146
- if (error?.name !== 'AbortPromptError') {
147
- throw error;
153
+ // Any prompt failurefall back to the safe default (auto-confirm Y) instead of
154
+ // crashing. This covers:
155
+ // - AbortPromptError: the 5s timeout fired (no input).
156
+ // - ExitPromptError / force-close: stdin is present but closed/EOF'd (the
157
+ // case under `mgr test`, agents, and other wrappers that pipe a non-readable
158
+ // stdin). inquirer throws "User force closed the prompt with 0 null" here.
159
+ // Anything unexpected is logged but still defaults to Y so unattended runs proceed.
160
+ const name = error?.name || '';
161
+ const known = name === 'AbortPromptError' || name === 'ExitPromptError';
162
+ if (!known) {
163
+ this.log(chalk.gray(` Prompt unavailable (${name || 'unknown'}) — auto-confirming (Y).`));
164
+ } else {
165
+ this.log(chalk.gray(' No input — auto-confirming (Y).'));
148
166
  }
149
- this.log(chalk.gray(' No input — auto-confirming (Y).'));
150
167
  shouldKill = true;
151
168
  }
152
169
 
@@ -155,6 +172,15 @@ class BaseCommand {
155
172
  return false;
156
173
  }
157
174
 
175
+ return this.killBlockingProcesses(blockedPorts);
176
+ }
177
+
178
+ /**
179
+ * Kill every process on the given blocked ports, then wait for release.
180
+ * @param {object[]} blockedPorts - [{ name, port, processes: [{ pid }] }]
181
+ * @returns {Promise<boolean>} - true if all killed (or already dead), false on failure
182
+ */
183
+ async killBlockingProcesses(blockedPorts) {
158
184
  // Kill ALL processes on each blocked port
159
185
  for (const { name, port, processes } of blockedPorts) {
160
186
  for (const { pid } of processes) {
@@ -23,10 +23,14 @@ npx mgr deploy # deploy to Firebase
23
23
  npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream)
24
24
  npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
25
25
  npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
26
+ npx mgr install dev # use LOCAL backend-manager source (to test framework edits)
27
+ npx mgr install prod # restore the published backend-manager from npm
26
28
  ```
27
29
 
28
30
  All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
29
31
 
32
+ > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install prod` to switch back.
33
+
30
34
  ## Where things live
31
35
 
32
36
  - `functions/index.js` — entry point. Must call `Manager.init(exports, { ... })` to register all built-in + custom endpoints.
@@ -3,6 +3,8 @@ const powertools = require('node-powertools');
3
3
  const moment = require('moment');
4
4
  const JSON5 = require('json5');
5
5
 
6
+ const { writeArticle, publishArticle } = require('../../../libraries/content/ghostii.js');
7
+
6
8
  const PROMPT = `
7
9
  Company: {brand.brand.name}: {brand.brand.description}
8
10
  Date: {date}
@@ -151,7 +153,11 @@ async function harvest(assistant, settings) {
151
153
  assistant.log('harvest(): Get final content', final);
152
154
 
153
155
  // Request to Ghostii
154
- const article = await requestGhostii(settings, final).catch((e) => e);
156
+ const article = await writeArticle({
157
+ brand: settings.brand,
158
+ description: final,
159
+ links: settings.links,
160
+ }).catch((e) => e);
155
161
  if (article instanceof Error) {
156
162
  assistant.error('harvest(): Error requesting Ghostii', article);
157
163
  break;
@@ -161,7 +167,13 @@ async function harvest(assistant, settings) {
161
167
  assistant.log('harvest(): Article', article);
162
168
 
163
169
  // Upload post to blog
164
- const uploadedPost = await uploadPost(assistant, settings, article).catch((e) => e);
170
+ const uploadedPost = await publishArticle(assistant, {
171
+ brand: settings.brand,
172
+ article,
173
+ id: postId++,
174
+ author: settings.author,
175
+ postPath: 'ghostii',
176
+ }).catch((e) => e);
165
177
  if (uploadedPost instanceof Error) {
166
178
  assistant.error('harvest(): Error uploading post to blog', uploadedPost);
167
179
  break;
@@ -197,51 +209,6 @@ function isURL(url) {
197
209
  }
198
210
  }
199
211
 
200
- function requestGhostii(settings, content) {
201
- return fetch('https://api.ghostii.ai/write/article', {
202
- method: 'post',
203
- timeout: 90000,
204
- tries: 1,
205
- response: 'json',
206
- body: {
207
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
208
- keywords: [''],
209
- description: content,
210
- insertLinks: true,
211
- headerImageUrl: 'unsplash',
212
- url: settings.brand.brand.url,
213
- sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
214
- feedUrl: `${settings.brand.brand.url}/feeds/posts.json`,
215
- links: settings.links,
216
- },
217
- });
218
- }
219
-
220
- function uploadPost(assistant, settings, article) {
221
- const apiUrl = `https://api.${(settings.brand.brand.url || '').replace(/^https?:\/\//, '')}`;
222
- return fetch(`${apiUrl}/backend-manager/admin/post`, {
223
- method: 'POST',
224
- timeout: 90000,
225
- tries: 1,
226
- response: 'json',
227
- body: {
228
- backendManagerKey: process.env.BACKEND_MANAGER_KEY,
229
- title: article.title,
230
- url: article.title, // This is formatted on the bm_api endpoint
231
- description: article.description,
232
- headerImageURL: article.headerImageUrl,
233
- body: article.body,
234
- id: postId++,
235
- author: settings.author,
236
- categories: article.categories,
237
- tags: article.keywords,
238
- postPath: 'ghostii',
239
- githubUser: settings.brand.github.user,
240
- githubRepo: settings.brand.github.repo,
241
- },
242
- });
243
- }
244
-
245
212
  function extractBodyContent(text, contentType, url) {
246
213
  const parsed = tryParse(text);
247
214
 
@@ -80,10 +80,12 @@ module.exports = async ({ Manager, assistant, libraries }) => {
80
80
  // Run the generator with imageHost forced to 'github' (production cron
81
81
  // path always uploads — that's what "production" means here) and the
82
82
  // campaignId pinned so all assets land in marketing-campaigns/{newId}/'s
83
- // matching folder.
83
+ // matching folder. publishArticle: true so the linked blog post (when
84
+ // config.article.enabled is on) is actually committed to the website repo.
84
85
  const generated = await generators[generator].generate(Manager, assistant, settings, {
85
86
  campaignId: newId,
86
87
  imageHost: 'github',
88
+ publishArticle: true,
87
89
  });
88
90
 
89
91
  if (!generated) {
@@ -395,6 +395,11 @@ OpenAI.prototype.request = function (options) {
395
395
  // Reasons
396
396
  options.reasoning = options.reasoning || undefined;
397
397
 
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.
400
+ options.tools = options.tools || undefined;
401
+ options.toolChoice = options.toolChoice || undefined;
402
+
398
403
  // Format prompt
399
404
  //
400
405
  // Accepts two forms:
@@ -977,6 +982,18 @@ function makeRequest(mode, options, self, promptSegments, message, user, _log) {
977
982
  if (reasoning) {
978
983
  request.body.reasoning = reasoning;
979
984
  }
985
+
986
+ // Only include tools (e.g. web_search) if provided. When present, the
987
+ // response output may contain tool-call items (e.g. web_search_call)
988
+ // alongside the message — the message extractor below already ignores
989
+ // non-message items, so this is purely additive.
990
+ if (Array.isArray(options.tools) && options.tools.length) {
991
+ request.body.tools = options.tools;
992
+
993
+ if (options.toolChoice) {
994
+ request.body.tool_choice = options.toolChoice;
995
+ }
996
+ }
980
997
  }
981
998
 
982
999
  // Request
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Ghostii article engine — shared helpers for AI article generation + publishing.
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).
8
+ *
9
+ * Two consumers:
10
+ * - events/cron/daily/ghostii-auto-publisher.js — standalone daily article job
11
+ * - libraries/email/generators/newsletter.js — newsletter-driven linked article
12
+ *
13
+ * Both call writeArticle() then publishArticle(). Kept here as the single source
14
+ * of truth for the Ghostii request shape and the admin/post payload.
15
+ */
16
+ const fetch = require('wonderful-fetch');
17
+ const powertools = require('node-powertools');
18
+
19
+ /**
20
+ * Generate an article via the Ghostii API.
21
+ *
22
+ * @param {object} args
23
+ * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { ... } })
24
+ * @param {string} args.description - The article brief / prompt content
25
+ * @param {string[]} [args.links] - Optional links to inject into the article body
26
+ * @returns {Promise<object>} { title, description, body, headerImageUrl, categories, keywords }
27
+ */
28
+ function writeArticle({ brand, description, links }) {
29
+ return fetch('https://api.ghostii.ai/write/article', {
30
+ method: 'post',
31
+ timeout: 90000,
32
+ tries: 1,
33
+ response: 'json',
34
+ body: {
35
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
36
+ keywords: [''],
37
+ description: description,
38
+ insertLinks: true,
39
+ headerImageUrl: 'unsplash',
40
+ url: brand.brand.url,
41
+ sectionQuantity: powertools.random(3, 6, { mode: 'gaussian' }),
42
+ feedUrl: `${brand.brand.url}/feeds/posts.json`,
43
+ links: links || [],
44
+ },
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Publish a Ghostii article to the brand's website repo via the admin/post route.
50
+ *
51
+ * @param {object} assistant - BEM assistant instance
52
+ * @param {object} args
53
+ * @param {object} args.brand - Public brand config ({ brand: { url, ... }, github: { user, repo } })
54
+ * @param {object} args.article - The article from writeArticle()
55
+ * @param {number} args.id - Post ID (unix timestamp)
56
+ * @param {string} [args.author] - Author slug (admin/post picks a default if unset)
57
+ * @param {string} [args.postPath='ghostii'] - Sub-folder under _posts/{year}/
58
+ * @returns {Promise<object>} { post, url, slug, path } — `url` is the public blog URL
59
+ */
60
+ async function publishArticle(assistant, { brand, article, id, author, postPath }) {
61
+ const baseUrl = (brand.brand.url || '').replace(/^https?:\/\//, '').replace(/\/$/, '');
62
+ const apiUrl = `https://api.${baseUrl}`;
63
+
64
+ const post = await fetch(`${apiUrl}/backend-manager/admin/post`, {
65
+ method: 'POST',
66
+ timeout: 90000,
67
+ tries: 1,
68
+ response: 'json',
69
+ body: {
70
+ backendManagerKey: process.env.BACKEND_MANAGER_KEY,
71
+ title: article.title,
72
+ url: article.title, // Slugified on the admin/post endpoint
73
+ description: article.description,
74
+ headerImageURL: article.headerImageUrl,
75
+ body: article.body,
76
+ id: id,
77
+ author: author,
78
+ categories: article.categories,
79
+ tags: article.keywords,
80
+ postPath: postPath || 'ghostii',
81
+ githubUser: brand.github.user,
82
+ githubRepo: brand.github.repo,
83
+ },
84
+ });
85
+
86
+ // admin/post returns the resolved `settings` (incl. the slugified `url` and repo `path`).
87
+ // The post template sets no permalink, so the public URL follows the Jekyll/UJM
88
+ // blog convention: {brand.url}/blog/{slug}.
89
+ const slug = post?.url || '';
90
+ const publicUrl = `${(brand.brand.url || '').replace(/\/$/, '')}/blog/${slug}`;
91
+
92
+ return {
93
+ post,
94
+ url: slug ? publicUrl : null,
95
+ slug,
96
+ path: post?.path || null,
97
+ };
98
+ }
99
+
100
+ module.exports = { writeArticle, publishArticle };
@@ -165,6 +165,7 @@ function getBodySections(structure, template) {
165
165
  body: d.dispatch,
166
166
  dataPoints: d.dataPoints,
167
167
  image_prompt: d.image_prompt,
168
+ cta: d.cta, // injected post-generation (e.g. linked-article "Read more")
168
169
  }));
169
170
  }
170
171
 
@@ -175,6 +176,7 @@ function getBodySections(structure, template) {
175
176
  title: s.title,
176
177
  body: s.body,
177
178
  image_prompt: s.image_prompt,
179
+ cta: s.cta, // injected post-generation (e.g. linked-article "Read more")
178
180
  }));
179
181
  }
180
182
 
@@ -236,6 +238,13 @@ function renderSection(section, idx, imagePaths, template) {
236
238
  lines.push(section.body);
237
239
  }
238
240
 
241
+ // CTA (e.g. "Read the full article →") — injected by code post-generation,
242
+ // never authored by the AI. The MJML template renders section.cta via
243
+ // sectionCard; this is the markdown equivalent for the Beehiiv-paste view.
244
+ if (section.cta?.url && section.cta?.label) {
245
+ lines.push(`[${section.cta.label} →](${section.cta.url})`);
246
+ }
247
+
239
248
  return lines.join('\n\n');
240
249
  }
241
250