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 +27 -0
- package/CLAUDE.md +3 -3
- package/README.md +1 -0
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +17 -0
- package/docs/consent.md +7 -4
- package/docs/marketing-campaigns.md +23 -1
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +30 -4
- package/src/defaults/CLAUDE.md +4 -0
- package/src/manager/events/cron/daily/ghostii-auto-publisher.js +14 -47
- package/src/manager/events/cron/daily/marketing-newsletter-generate.js +3 -1
- package/src/manager/libraries/ai/providers/openai.js +17 -0
- package/src/manager/libraries/content/ghostii.js +100 -0
- package/src/manager/libraries/email/generators/lib/markdown-renderer.js +9 -0
- package/src/manager/libraries/email/generators/newsletter.js +148 -3
- package/src/manager/routes/user/signup/post.js +129 -38
- package/src/manager/schemas/user/signup/post.js +12 -8
- package/src/test/test-accounts.js +25 -0
- package/templates/backend-manager-config.json +9 -2
- package/test/marketing/newsletter-generate.js +70 -7
- package/test/routes/user/signup.js +168 -0
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
|
|
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
|
package/docs/admin-post-route.md
CHANGED
|
@@ -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., ``)
|
package/docs/ai-library.md
CHANGED
|
@@ -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'` + **
|
|
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
|
-
**
|
|
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
|
@@ -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
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
153
|
+
// Any prompt failure → fall 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) {
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|