backend-manager 5.10.2 → 5.10.3
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/CLAUDE.md +3 -1
- package/docs/admin-post-route.md +8 -5
- package/docs/cdp-debugging.md +15 -30
- package/docs/ghostii.md +18 -11
- package/docs/marketing-campaigns.md +11 -7
- package/package.json +1 -1
- package/src/cli/index.js +7 -1
- package/src/defaults/CLAUDE.md +2 -0
- package/src/manager/events/cron/daily/blog-auto-publisher.js +61 -150
- package/src/manager/libraries/content/source-resolver.js +296 -109
- package/src/manager/libraries/email/generators/newsletter.js +147 -69
- package/src/manager/routes/admin/post/post.js +69 -1
- package/src/test/fixtures/firebase-project/.temp/test-mode.json +1 -1
- package/src/test/fixtures/firebase-project/database-debug.log +8 -8
- package/src/test/fixtures/firebase-project/firestore-debug.log +55 -57
- package/src/test/fixtures/firebase-project/pubsub-debug.log +3 -3
- package/templates/backend-manager-config.json +1 -0
- package/test/email/newsletter-generate.js +4 -5
- package/test/helpers/content/blog-auto-publisher.js +116 -132
- package/test/helpers/content/ghostii-feed-integration.js +2 -2
- package/test/routes/admin/post-convert-image.js +158 -0
- package/test/routes/admin/post-download-error.js +89 -0
package/CLAUDE.md
CHANGED
|
@@ -98,7 +98,7 @@ See [docs/cli-firestore-auth.md](docs/cli-firestore-auth.md) and [docs/cli-logs.
|
|
|
98
98
|
- **🚫 NEVER run `npx mgr serve` / `npx mgr emulator`** (consumer projects) — they're the user's long-running dev processes. Assume they're already running; if they aren't, **instruct the user to run them** rather than running them yourself (running them again kills theirs). To see output, **read the `functions/*.log` files** (`dev.log`, `emulator.log`, `test.log`) — never tail/attach to the process. Running `npx mgr test` is fine (it auto-starts its own emulator if needed).
|
|
99
99
|
- **Where the output logs live:** BEM CLI commands tee output to `<projectDir>/functions/` (not `logs/` — BEM's deliberate exception, co-located with firebase-tools' `*-debug.log`): `dev.log` (`npx mgr serve`), `deploy.log` (`npx mgr deploy`), `emulator.log` (`npx mgr emulator` / test with own emulator), `test.log` (`npx mgr test`), `production.log` (`npx mgr logs`). The `dev`/`test` names match EM/BXM/UJM; see [docs/logging.md](docs/logging.md).
|
|
100
100
|
- **If the user reports an error**, check the emulator/test output for the root cause before guessing.
|
|
101
|
-
- **Live-test UI changes via CDP.** When working on admin dashboards or browser-facing endpoints, use the `chrome-devtools` MCP tools (screenshots, click, evaluate JS, console logs) to verify the change works in the running browser. See `~/.claude/mcp-server/servers/chrome-devtools/CLAUDE.md`.
|
|
101
|
+
- **Live-test UI changes via CDP.** When working on admin dashboards or browser-facing endpoints, use the `chrome-devtools` MCP tools (screenshots, click, evaluate JS, console logs) to verify the change works in the running browser — your session auto-launches its own private Chrome on the first tool call (no setup, no ports). See [docs/cdp-debugging.md](docs/cdp-debugging.md) + `~/.claude/mcp-server/servers/chrome-devtools/CLAUDE.md`.
|
|
102
102
|
|
|
103
103
|
## Supply-Chain Security
|
|
104
104
|
|
|
@@ -133,6 +133,8 @@ Whenever you make a behavioral change (new command, new flag, new pattern, remov
|
|
|
133
133
|
|
|
134
134
|
Don't ship behavioral changes with stale docs. Validate first, then document — write docs that describe shipped reality, not intentions.
|
|
135
135
|
|
|
136
|
+
**The OMEGA docs are structurally MIRRORED.** This file's section skeleton, the consumer template (`src/defaults/CLAUDE.md`), shared-concept `docs/*.md` filenames, and the `omega:*` skills are identical in structure and order across the sister frameworks (UJM / BEM / BXM / EM / MAM — WM mirrors the library subset). Never add, rename, or reorder a section here without making the SAME change in every sister repo in the same pass. The canonical skeletons + omission rules live in the `omega:main` skill's `mirror-spec.md` resource.
|
|
137
|
+
|
|
136
138
|
## Documentation
|
|
137
139
|
|
|
138
140
|
Deep references live in `docs/`. **Whenever you make a behavioral change, update both this overview AND the relevant `docs/*.md` deep reference.**
|
package/docs/admin-post-route.md
CHANGED
|
@@ -11,10 +11,13 @@ The `POST /admin/post` route creates blog posts via GitHub's API. It handles ima
|
|
|
11
11
|
1. Receives markdown body with external image URLs (e.g., ``)
|
|
12
12
|
2. Extracts all `` patterns from the body using regex
|
|
13
13
|
3. Downloads each image to a tmp dir
|
|
14
|
-
4. **
|
|
15
|
-
5.
|
|
16
|
-
6.
|
|
17
|
-
7.
|
|
14
|
+
4. **Converts** png/webp sources to progressive JPEG in place (`convertToJpeg` — alpha flattened onto white); other non-JPG formats are rejected, naming the offending URL
|
|
15
|
+
5. **Resizes** each image in place if its long edge exceeds `IMAGE_MAX_DIMENSION` (see below)
|
|
16
|
+
6. Commits all images to `src/assets/images/blog/post-{id}/` on GitHub (single commit via Git Trees API)
|
|
17
|
+
7. **Rewrites the body** to replace external URLs with `@post/{filename}` format
|
|
18
|
+
8. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
|
|
19
|
+
|
|
20
|
+
**Download failures:** the header image is fatal (the whole request 400s); body images are skipped with a warning (the body keeps the original external URL — the post still publishes). Failure reasons go through `formatImageDownloadError` — `Could not download image (<url>): <reason>` with HTML stripped from the reason (CDN 404 pages return raw HTML bodies) and long reasons truncated, so callers (e.g. sponsorship failure emails) surface something readable. Unsupported formats (anything that isn't jpg/png/webp) are rejected, naming the offending URL. Tests: `test/routes/admin/post-download-error.js` + `test/routes/admin/post-convert-image.js`.
|
|
18
21
|
|
|
19
22
|
## Image resize
|
|
20
23
|
|
|
@@ -22,7 +25,7 @@ Sources from guest-post submissions can be enormous (16384×10576 has been seen
|
|
|
22
25
|
|
|
23
26
|
Two defenses:
|
|
24
27
|
|
|
25
|
-
1. **CDN pre-scale** — `applyImageCDNParams(src)` adds server-side resize params to supported CDN URLs *before* downloading. Currently supports Unsplash (`images.unsplash.com
|
|
28
|
+
1. **CDN pre-scale** — `applyImageCDNParams(src)` adds server-side resize params to supported CDN URLs *before* downloading. Currently supports Unsplash (`images.unsplash.com`, Imgix-style `?w=&q=` params) and Pexels (`images.pexels.com`, `?w=&auto=compress`). The CDN delivers a pre-scaled image (e.g. ~314KB instead of 3.8MB for a 2048px cap), so sharp never sees the massive original. Params are only added if not already present on the URL.
|
|
26
29
|
|
|
27
30
|
2. **Local sharp resize** — after download, `resizeImage()` checks the long edge against `IMAGE_MAX_DIMENSION` and re-encodes as progressive JPEG at `IMAGE_JPEG_QUALITY` if it exceeds the limit. Images already within the limit pass through untouched. `sharp.cache(false)` is set so decoded pixel buffers are freed immediately between images — without this, processing several images serially can OOM even at 256MB.
|
|
28
31
|
|
package/docs/cdp-debugging.md
CHANGED
|
@@ -1,44 +1,29 @@
|
|
|
1
1
|
# CDP Debugging (driving a live browser)
|
|
2
2
|
|
|
3
|
-
How to
|
|
3
|
+
How to drive a browser you can CONTROL — see the frontend live, screenshot it, click, type, read console logs, inspect network requests against your routes — for agents (Claude via MCP/CDP) and humans.
|
|
4
4
|
|
|
5
|
-
> Mirrored across the
|
|
5
|
+
> Mirrored across the five sister frameworks (UJM / BEM / BXM / EM / WM) — same core section, framework-flavored. Edit all five together.
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## The browser: your Claude session owns one
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
open -gna "Google Chrome" --args \
|
|
11
|
-
--remote-debugging-port=9223 \
|
|
12
|
-
--user-data-dir="$HOME/Library/Application Support/chrome-profiles/agent" \
|
|
13
|
-
--no-first-run --no-default-browser-check \
|
|
14
|
-
--disable-background-timer-throttling \
|
|
15
|
-
--disable-backgrounding-occluded-windows \
|
|
16
|
-
--disable-renderer-backgrounding \
|
|
17
|
-
https://localhost:4000 # ← the frontend talking to your backend (or the IP from .temp/_config_browsersync.yml if localhost doesn't connect)
|
|
18
|
-
```
|
|
9
|
+
Browser work runs through the **`chrome-devtools` MCP** (via mcp-router). There is NO launch procedure anymore — no ports, no profile dirs, no curl checks:
|
|
19
10
|
|
|
20
|
-
|
|
11
|
+
- **Just call the tools** — `new_page`, `navigate_page`, `take_screenshot`, `click`, `fill`, `evaluate_script`, `list_console_messages`, `list_network_requests`. The browser auto-launches on the first call.
|
|
12
|
+
- **Each Claude session gets its OWN private Chrome** (`--isolated`): temp profile, CDP over an internal pipe. Parallel sessions cannot see or touch each other's pages — open and close pages freely, the whole browser is yours.
|
|
13
|
+
- **It dies with the session.** No orphans, no cleanup, nothing to kill.
|
|
14
|
+
- **Ephemeral profile** — cookies/logins do NOT persist between sessions. If a flow needs auth, log in during the task.
|
|
15
|
+
- **Self-signed HTTPS is pre-accepted** (`--acceptInsecureCerts` in the upstream) — dev servers load without certificate interstitials.
|
|
16
|
+
- **NEVER quit/kill Chrome by app name** (`killall "Google Chrome"`, osascript) — that's the user's personal browser, not yours.
|
|
21
17
|
|
|
22
|
-
|
|
18
|
+
Humans: the agent's Chrome window is visible — you can watch it drive. Full reference: `~/.claude/mcp-server/servers/chrome-devtools/CLAUDE.md`.
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
- **`--user-data-dir` is REQUIRED, not optional.** Chrome 136+ **silently ignores** `--remote-debugging-port` on the default profile — no error, no port, nothing (verified on Chrome 149). This is the #1 "why isn't CDP up" trap.
|
|
26
|
-
- **The profile dir IS the persistent login state.** Cookies + localStorage survive relaunches (verified by round-trip). **Log into sites once in the agent profile and every agent reuses the authenticated state** — auth'd flows against your routes work without re-login. Ecosystem convention: ONE shared profile at `~/Library/Application Support/chrome-profiles/agent` across all four frameworks, so logins are a one-time setup.
|
|
27
|
-
- **One Chrome instance per profile dir — but MANY agents per instance.** CDP is multi-client (verified: two concurrent clients driving different tabs of one instance): agents and sessions attach to the SAME port, each drives its own tab, and all share the profile's logins. One agent per tab is the only rule. A second launch with the same dir just opens a window in the existing instance and **ignores the new debug port** — attach to the running one instead. Reach for a second profile + port (`…/b` on 9224) only for a different IDENTITY (a different account = a different cookie jar) or hard isolation.
|
|
28
|
-
- It runs **side-by-side with the daily Chrome** — a different `--user-data-dir` is a fully separate instance.
|
|
29
|
-
- **Quit by profile match, never by app name**: `pkill -f "chrome-profiles/agent"`. (`osascript 'tell app "Google Chrome" to quit'` hits the daily browser too — same app name.)
|
|
20
|
+
## Electron apps are the exception (attach, don't launch)
|
|
30
21
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
| Client | Good for | Port handoff |
|
|
34
|
-
|---|---|---|
|
|
35
|
-
| `chrome-devtools` MCP | rich interaction — click, fill, type, screenshots, network requests, console messages, performance traces | `CHROME_CDP_PORT` env var, **expanded ONCE when the Claude session spawns its MCP — set it BEFORE launching `claude`** (mid-session changes do nothing) |
|
|
36
|
-
| Any CDP client — including EM's `npx mgr cdp` run from any EM project | quick JS eval, per-renderer screenshots | per invocation: `EM_CDP_PORT=9223 npx mgr cdp eval ":4000" 'document.title'` |
|
|
37
|
-
|
|
38
|
-
Port conventions: **9222** = Electron apps (EM), **9223+** = Chrome instances.
|
|
22
|
+
An Electron dev app is a running singleton — you ATTACH to it instead of launching a browser: the `chrome-devtools-electron` MCP upstream (reads `EM_CDP_PORT`, default 9222, expanded once at session start) or EM's per-invocation `npx mgr cdp`. See EM's `docs/cdp-debugging.md`.
|
|
39
23
|
|
|
40
24
|
## BEM specifics
|
|
41
25
|
|
|
42
|
-
- **The UJM dev site is
|
|
26
|
+
- **The UJM dev site URL is `https://localhost:4000` — NEVER the LAN IP** (`https://192.168.x.x:...`). Port 4000 by default, increments (4001, …) when multiple sites run; the exact port is in `.temp/_config_browsersync.yml` at the root of the WEBSITE project (the UJM consumer — e.g. `<brand>-website/.temp/_config_browsersync.yml`, NOT this backend repo).
|
|
43
27
|
- The network tab is the payoff: `list_network_requests` shows every call the frontend makes to your routes — method, status, and payloads — while you click through the real UI.
|
|
28
|
+
- **Auth'd flows**: the profile is ephemeral, so log in through the real UI at the start of the session (test creds) — then exercise the authenticated routes.
|
|
44
29
|
- Backend-side observation stays where it always was: `npx mgr logs` (gcloud logs) and the emulator suite; this doc only covers the browser half of the loop.
|
package/docs/ghostii.md
CHANGED
|
@@ -14,13 +14,13 @@ config.blog.content[] entry
|
|
|
14
14
|
|
|
|
15
15
|
blog-auto-publisher cron (daily)
|
|
16
16
|
| (per entry, per article)
|
|
17
|
-
|
|
17
|
+
resolveSources({ count: 1 }) -- unified resolver: random pick + type fallback + Firestore dedup
|
|
18
18
|
|
|
|
19
19
|
provider.writeArticle() -- call provider API (e.g. Ghostii: api.ghostii.ai/write/article)
|
|
20
20
|
|
|
|
21
|
-
provider.publishArticle() -- POST to admin/post -> GitHub commit
|
|
21
|
+
provider.publishArticle() -- POST to admin/post -> GitHub commit (passes source URL)
|
|
22
22
|
|
|
|
23
|
-
trackContentSource() -- Firestore
|
|
23
|
+
trackContentSource() -- Firestore, AFTER successful publish
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
### Key files
|
|
@@ -90,21 +90,28 @@ content-sources/{sha256(origin + '::' + url).slice(0,20)}: {
|
|
|
90
90
|
|
|
91
91
|
### Topic deduplication
|
|
92
92
|
|
|
93
|
-
The blog auto-publisher prevents near-duplicate articles at
|
|
93
|
+
The blog auto-publisher prevents near-duplicate articles at two levels:
|
|
94
94
|
|
|
95
|
-
1. **Per-
|
|
95
|
+
1. **Per-item dedup** (structural): the resolver queries `content-sources` before picking — feed items by `feedUrl`, parent sources by `origin == '$parent'`. Used items are never re-picked. Within one `resolveSources()` call, a session-used set also prevents the same item from resolving twice.
|
|
96
96
|
|
|
97
|
-
2. **
|
|
97
|
+
2. **Topic dedup** (prompt-based): Before generating each article, `harvest()` appends all recent titles (Firestore history + same-run) to the Ghostii prompt as a strict avoidance list. The prompt forbids writing about the same topic, theme, or keyword combination — not just the same story.
|
|
98
98
|
|
|
99
|
-
|
|
99
|
+
Within a single cron run, both generated post titles AND source item titles are tracked in `runTitles` and fed into the prompt for subsequent articles.
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
Sources are marked used in `content-sources` ONLY after the article actually publishes ($brand posts get a synthetic tracking entry so recent-title dedup still sees them).
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
### Source picking + fallback hierarchy
|
|
104
104
|
|
|
105
|
-
|
|
105
|
+
Both blog and newsletter resolve through the same `resolveSources()` (source-resolver.js). Each needed source starts as a RANDOM pick from the entry's `sources` array — nothing outside the array is ever used. On failure, a strict type hierarchy applies:
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
| Picked type | Fallback chain |
|
|
108
|
+
|---|---|
|
|
109
|
+
| `$feed:` | other items in the same feed → other `$feed` sources (random order) → `$parent` (only if listed) → give up |
|
|
110
|
+
| `$parent` | other unused parent-pool items → give up (never falls to feeds) |
|
|
111
|
+
| `$brand` | none — resolves directly; **nothing ever falls back TO `$brand`** |
|
|
112
|
+
| URL / text | none — pick-only |
|
|
113
|
+
|
|
114
|
+
If a pick's chain is exhausted, that slot is unfilled: the blog skips that article, the newsletter proceeds with fewer sources (or skips entirely at zero).
|
|
108
115
|
|
|
109
116
|
## Configuration
|
|
110
117
|
|
|
@@ -156,11 +156,11 @@ Per-brand subusers provide full contact/segment/field isolation under one billin
|
|
|
156
156
|
`generators/newsletter.js` orchestrates a multi-step pipeline that produces a fully rendered, email-safe newsletter. Output is HTML (not markdown) — the marketing library detects `settings.contentHtml` and uses it directly, skipping the markdown pipeline.
|
|
157
157
|
|
|
158
158
|
Pipeline:
|
|
159
|
-
1.
|
|
159
|
+
1. Resolve sources: `resolveSources({ sources, count: sourceCount || 6, categories })` — the unified blog/newsletter resolver (source-resolver.js). Each pick is a RANDOM source from the entry's `sources` array; failures follow the type hierarchy ($feed → other feeds → $parent; $parent → other parent items; nothing falls to $brand). Firestore-checked so used items never repeat, session-checked so one issue never gets duplicates. See [docs/ghostii.md](ghostii.md#source-picking--fallback-hierarchy) for the full hierarchy.
|
|
160
160
|
2. **structure.js** — Generic dispatcher. Resolves the active template, merges `BASE_SCHEMA` (universal fields: subject, preheader, signoff, citations) with the template's own `schema` fragment, calls the template's `buildPrompt({brand, newsletterConfig, sources})` to get the AI brief, runs the AI call, and normalizes the result via the template's optional `normalize()`. Default provider: `openai` (override per-run only via `NEWSLETTER_PROVIDER_STRUCTURE` env).
|
|
161
161
|
3. **image-illustrator.js** (default) — One flat-vector PNG per section in parallel (`Promise.all`), generated directly via `Manager.AI(assistant).image()` → `gpt-image-2`. Iterates `structure.sections` — templates whose content shape isn't section-based (e.g. field-report uses `dispatches`) populate `sections` in their `normalize()` step so this loop keeps working unchanged. The prompt enforces a clean flat 2D vector style (Stripe / Linear / undraw.co aesthetic) built from the brand palette (`content.theme.{primary,secondary,accent}Color`), on a white background, no text. **Legacy method:** set `marketing.newsletter.content.method.image = 'svg'` to use the older `svg-illustrator.js` (AI authors an `<svg>`, rasterized via `@resvg/resvg-js`). Both methods return the same `{ png: Buffer, fallback, meta }` contract.
|
|
162
162
|
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.
|
|
163
|
-
5. Mark used: `
|
|
163
|
+
5. Mark used: `trackContentSource()` per source into the local `content-sources` collection — ONLY after generation succeeds. No PUT to the parent; the child tracks its own usage.
|
|
164
164
|
|
|
165
165
|
> **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.
|
|
166
166
|
|
|
@@ -283,12 +283,16 @@ Templates add their own fields on top (e.g. classic adds `intro` + `sections`; f
|
|
|
283
283
|
|
|
284
284
|
**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.newsletter.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.
|
|
285
285
|
|
|
286
|
-
**
|
|
287
|
-
-
|
|
286
|
+
**Newsletter report email — always sent.** After every generation, the generator sends an internal report email via `sender: 'internal'` (resolves to `alerts@{brandDomain}`) with:
|
|
287
|
+
- Beehiiv status (uploaded, or the failure reason when it failed — subject line adapts)
|
|
288
|
+
- A prominent "Preview Newsletter" button (GitHub Pages viewer)
|
|
288
289
|
- Subject, preheader, tags
|
|
289
|
-
- Direct links to the rendered HTML, per-section markdown,
|
|
290
|
+
- Direct links to the rendered HTML, per-section markdown, and summary
|
|
291
|
+
- Linked articles (published ones only — unpublished articles are never referenced)
|
|
292
|
+
- All sources used (linked when they have real URLs, feed hostname in parentheses)
|
|
293
|
+
- The full GitHub folder
|
|
290
294
|
|
|
291
|
-
This means the newsletter is never "stuck" — even with Beehiiv disabled or failing, you get an actionable email pointing to ready-to-paste assets. The
|
|
295
|
+
This means the newsletter is never "stuck" — even with Beehiiv disabled or failing, you get an actionable email pointing to ready-to-paste assets. The report is best-effort; failure to send is logged but does not block the Firestore campaign-doc write.
|
|
292
296
|
|
|
293
297
|
Requires `GH_TOKEN` env var (org-scoped, write access to `newsletter-assets`). Without it, the cron's HTML/image upload calls throw and the run aborts.
|
|
294
298
|
|
|
@@ -307,7 +311,7 @@ Requires `GH_TOKEN` env var (org-scoped, write access to `newsletter-assets`). W
|
|
|
307
311
|
| `lib/templates/shared-campaign.js` | Shared utilities for all templates (`escape()`, `resolveTheme()`, `formatAddress()`) |
|
|
308
312
|
| `lib/templates/editorial/helpers.js` | Editorial-only helpers (pullquote, issue number, eyebrow) |
|
|
309
313
|
| `lib/templates/field-report/helpers.js` | Field-report-only helpers (kicker, dispatch dateline, terminal block, terminator) |
|
|
310
|
-
| `newsletter.js` | Orchestrator — calls lib modules,
|
|
314
|
+
| `newsletter.js` | Orchestrator — resolves sources via the unified source-resolver, calls lib modules, tracks usage locally |
|
|
311
315
|
| `test/marketing/fixtures/{name}.json` | Hand-crafted structure per template (loaded by iteration test in fixture mode) |
|
|
312
316
|
|
|
313
317
|
## Seed Campaigns
|
package/package.json
CHANGED
package/src/cli/index.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
const os = require('os');
|
|
2
2
|
const path = require('path');
|
|
3
|
-
|
|
3
|
+
// Universal boolean flags must be declared — otherwise yargs treats the next
|
|
4
|
+
// positional as the flag's VALUE (`mgr test --extended project:foo` became
|
|
5
|
+
// extended='project:foo' with NO targets, silently running EVERYTHING in
|
|
6
|
+
// extended mode against real external APIs).
|
|
7
|
+
const argv = require('yargs')(process.argv.slice(2))
|
|
8
|
+
.boolean(['extended', 'legacy', 'force', 'raw', 'emulator'])
|
|
9
|
+
.argv;
|
|
4
10
|
const _ = require('lodash');
|
|
5
11
|
|
|
6
12
|
// Abort if running from ~/node_modules (accidental home directory install)
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# ========== Default Values ==========
|
|
2
2
|
# Backend Manager (BEM) — consumer project
|
|
3
3
|
|
|
4
|
+
<!-- MAINTAINERS (framework repo): this consumer template is MIRRORED across UJM/BEM/BXM/EM/MAM — same sections, same order (framework-specific extras may be inserted; canonical sections are never reordered/renamed). Edit all five together. Canonical skeleton: omega:main skill → resources/mirror-spec.md -->
|
|
5
|
+
|
|
4
6
|
## Framework
|
|
5
7
|
|
|
6
8
|
This project consumes **Backend Manager** (BEM) — a comprehensive framework for building modern Firebase Cloud Functions backends. BEM provides a single `Manager.init(exports, {...})` bootstrap that wires built-in functions (`bm_api`, auth events, cron jobs), helper classes (Assistant, User, Analytics, Usage, Middleware, Settings, Utilities, Metadata), payment processor integrations (Stripe / PayPal), Firestore-trigger pipelines, and a deploy/emulator/watch tooling pipeline.
|
|
@@ -13,13 +13,11 @@
|
|
|
13
13
|
* 'https://...' — fetch URL content as prompt seed
|
|
14
14
|
* '<text>' — use directly as prompt seed
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* Source resolution, tracking, and prompt constants live in the shared
|
|
22
|
-
* source-resolver library (src/manager/libraries/content/source-resolver.js).
|
|
16
|
+
* Source picking, fallback hierarchy, Firestore dedup, and tracking live in
|
|
17
|
+
* the shared source-resolver library — the SAME resolution the newsletter
|
|
18
|
+
* generator uses. Per article, ONE random source is picked from the pool;
|
|
19
|
+
* failures follow the type hierarchy ($feed → other feeds → $parent; $parent
|
|
20
|
+
* → other parent items; NOTHING falls back to $brand).
|
|
23
21
|
*/
|
|
24
22
|
const powertools = require('node-powertools');
|
|
25
23
|
const moment = require('moment');
|
|
@@ -27,15 +25,9 @@ const moment = require('moment');
|
|
|
27
25
|
const {
|
|
28
26
|
PROMPT_SOURCE,
|
|
29
27
|
PROMPT,
|
|
30
|
-
|
|
31
|
-
processFeedSource,
|
|
32
|
-
processParentSource,
|
|
28
|
+
resolveSources,
|
|
33
29
|
trackContentSource,
|
|
34
|
-
contentSourceHash,
|
|
35
|
-
getProcessedItemIds,
|
|
36
30
|
getRecentTitles,
|
|
37
|
-
getURLContent,
|
|
38
|
-
isURL,
|
|
39
31
|
} = require('../../../libraries/content/source-resolver.js');
|
|
40
32
|
|
|
41
33
|
let postId;
|
|
@@ -62,7 +54,7 @@ module.exports = async ({ Manager, assistant, context, libraries }) => {
|
|
|
62
54
|
|
|
63
55
|
for (const entry of contentArray) {
|
|
64
56
|
entry.quantity = entry.quantity || 0;
|
|
65
|
-
entry.sources =
|
|
57
|
+
entry.sources = entry.sources || [];
|
|
66
58
|
entry.links = randomize(entry.links || []);
|
|
67
59
|
entry.instructions = entry.instructions || '';
|
|
68
60
|
entry.tone = entry.tone || 'professional';
|
|
@@ -135,25 +127,28 @@ async function harvest(assistant, entry, admin, provider, Manager) {
|
|
|
135
127
|
}
|
|
136
128
|
|
|
137
129
|
for (let index = 0; index < entry.quantity; index++) {
|
|
138
|
-
|
|
130
|
+
assistant.log(`harvest(): Processing ${index + 1}/${entry.quantity}`);
|
|
139
131
|
|
|
140
|
-
|
|
141
|
-
const shuffled = randomize([...entry.sources]);
|
|
142
|
-
for (const source of shuffled) {
|
|
143
|
-
assistant.log(`harvest(): Processing ${index + 1}/${entry.quantity}`, source);
|
|
132
|
+
const allKnownTitles = [...recentTitles, ...runTitles];
|
|
144
133
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
134
|
+
// ONE random source per article — fallback hierarchy handled by the resolver.
|
|
135
|
+
// Tracking between iterations means the next resolve sees this run's usage.
|
|
136
|
+
const [source] = await resolveSources({
|
|
137
|
+
sources: entry.sources,
|
|
138
|
+
count: 1,
|
|
139
|
+
categories: entry.categories,
|
|
140
|
+
admin,
|
|
141
|
+
Manager,
|
|
142
|
+
assistant,
|
|
143
|
+
});
|
|
151
144
|
|
|
152
|
-
if (!
|
|
153
|
-
assistant.log('harvest():
|
|
145
|
+
if (!source) {
|
|
146
|
+
assistant.log('harvest(): No source could be resolved for this article, skipping');
|
|
154
147
|
continue;
|
|
155
148
|
}
|
|
156
149
|
|
|
150
|
+
const resolved = buildPromptFromSource(source, entry);
|
|
151
|
+
|
|
157
152
|
if (allKnownTitles.length) {
|
|
158
153
|
resolved.description += '\n\nTOPIC DEDUPLICATION (STRICT):\n'
|
|
159
154
|
+ 'These articles were RECENTLY published on our blog. You MUST NOT write about the same topic, theme, or subject area as ANY of them.\n'
|
|
@@ -165,7 +160,7 @@ async function harvest(assistant, entry, admin, provider, Manager) {
|
|
|
165
160
|
+ allKnownTitles.slice(0, 25).map((t) => `- ${t}`).join('\n');
|
|
166
161
|
}
|
|
167
162
|
|
|
168
|
-
assistant.log('harvest(): Resolved source',
|
|
163
|
+
assistant.log('harvest(): Resolved source', { type: source.type, title: source.title, url: source.url });
|
|
169
164
|
|
|
170
165
|
const overrides = { ...entry.overrides };
|
|
171
166
|
if (!overrides.keywords && entry.keywords.length) {
|
|
@@ -195,7 +190,7 @@ async function harvest(assistant, entry, admin, provider, Manager) {
|
|
|
195
190
|
id: postId++,
|
|
196
191
|
author: entry.author,
|
|
197
192
|
postPath: entry.postPath,
|
|
198
|
-
source:
|
|
193
|
+
source: source.trackingData?.url || null,
|
|
199
194
|
};
|
|
200
195
|
assistant.log('harvest(): publishArgs', publishArgs);
|
|
201
196
|
|
|
@@ -210,21 +205,22 @@ async function harvest(assistant, entry, admin, provider, Manager) {
|
|
|
210
205
|
if (generatedTitle) {
|
|
211
206
|
runTitles.push(generatedTitle);
|
|
212
207
|
}
|
|
213
|
-
if (
|
|
214
|
-
runTitles.push(
|
|
208
|
+
if (source.trackingData?.itemTitle) {
|
|
209
|
+
runTitles.push(source.trackingData.itemTitle);
|
|
215
210
|
}
|
|
216
211
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
212
|
+
// Mark the source used ONLY now that the article actually published.
|
|
213
|
+
// $brand/url/text picks have no trackingData — synthesize one for $brand
|
|
214
|
+
// so recent-title dedup still sees the post.
|
|
215
|
+
const trackingData = source.trackingData || {
|
|
216
|
+
url: uploadedPost.slug || `brand-${postId}`,
|
|
217
|
+
origin: '$brand',
|
|
218
|
+
};
|
|
224
219
|
|
|
225
220
|
if (admin) {
|
|
226
221
|
await trackContentSource(admin, {
|
|
227
|
-
...
|
|
222
|
+
...trackingData,
|
|
223
|
+
usedBy: 'blog',
|
|
228
224
|
brandId: entry.brand.brand.id,
|
|
229
225
|
postUrl: uploadedPost.url,
|
|
230
226
|
postSlug: uploadedPost.slug,
|
|
@@ -236,133 +232,48 @@ async function harvest(assistant, entry, admin, provider, Manager) {
|
|
|
236
232
|
}
|
|
237
233
|
}
|
|
238
234
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
235
|
+
/**
|
|
236
|
+
* Map a resolved source to the provider prompt shape.
|
|
237
|
+
* feed/parent sources rewrite an existing article (PROMPT_SOURCE);
|
|
238
|
+
* brand/url/text sources seed a fresh topic (PROMPT).
|
|
239
|
+
*
|
|
240
|
+
* @param {object} source - resolved source from resolveSources()
|
|
241
|
+
* @param {object} entry - the blog content entry
|
|
242
|
+
* @returns {{ description: string, sourceContent: string }}
|
|
243
|
+
*/
|
|
244
|
+
function buildPromptFromSource(source, entry) {
|
|
242
245
|
const templateVars = {
|
|
243
246
|
...entry,
|
|
244
247
|
instructions: entry.instructions,
|
|
245
|
-
date:
|
|
248
|
+
date: moment().format('MMMM YYYY'),
|
|
246
249
|
tone: entry.tone || '',
|
|
247
250
|
categories: (entry.categories || []).join(', '),
|
|
248
251
|
keywords: (entry.keywords || []).join(', '),
|
|
249
252
|
};
|
|
250
253
|
|
|
251
|
-
|
|
252
|
-
if (typeof source === 'string' && source.startsWith(FEED_PREFIX)) {
|
|
253
|
-
const feedUrl = source.slice(FEED_PREFIX.length);
|
|
254
|
-
assistant.log(`resolveSource(): Processing feed: ${feedUrl}`);
|
|
255
|
-
|
|
256
|
-
const feedResult = await processFeedSource(assistant, feedUrl, entry.brand.brand.id, admin).catch((e) => e);
|
|
257
|
-
|
|
258
|
-
if (feedResult instanceof Error || !feedResult) {
|
|
259
|
-
assistant.log('resolveSource(): Feed failed or exhausted');
|
|
260
|
-
if (entry.sources.includes('$brand')) {
|
|
261
|
-
return resolveSource(assistant, '$brand', entry, admin, Manager);
|
|
262
|
-
}
|
|
263
|
-
return null;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const description = powertools.template(PROMPT_SOURCE, {
|
|
267
|
-
...templateVars,
|
|
268
|
-
sourceTitle: feedResult.item.title,
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
return {
|
|
272
|
-
description,
|
|
273
|
-
sourceContent: feedResult.content || feedResult.item.summary || '',
|
|
274
|
-
trackingData: {
|
|
275
|
-
url: feedResult.item.url || feedResult.item.id,
|
|
276
|
-
origin: source,
|
|
277
|
-
feedUrl: feedUrl,
|
|
278
|
-
itemId: feedResult.item.id,
|
|
279
|
-
itemTitle: feedResult.item.title,
|
|
280
|
-
usedBy: 'blog',
|
|
281
|
-
},
|
|
282
|
-
};
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
// --- $parent source ---
|
|
286
|
-
if (source === '$parent') {
|
|
287
|
-
assistant.log('resolveSource(): Processing $parent source');
|
|
288
|
-
|
|
289
|
-
const parentResults = await processParentSource(assistant, entry, admin, Manager).catch((e) => e);
|
|
290
|
-
|
|
291
|
-
if (parentResults instanceof Error || !parentResults?.length) {
|
|
292
|
-
assistant.log('resolveSource(): Parent source failed or exhausted');
|
|
293
|
-
if (entry.sources.includes('$brand')) {
|
|
294
|
-
return resolveSource(assistant, '$brand', entry, admin, Manager);
|
|
295
|
-
}
|
|
296
|
-
return null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
const parentResult = parentResults[0];
|
|
300
|
-
const description = powertools.template(PROMPT_SOURCE, {
|
|
301
|
-
...templateVars,
|
|
302
|
-
sourceTitle: parentResult.title,
|
|
303
|
-
});
|
|
304
|
-
|
|
254
|
+
if (source.type === 'feed' || source.type === 'parent') {
|
|
305
255
|
return {
|
|
306
|
-
description,
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
itemId: parentResult.id,
|
|
312
|
-
itemTitle: parentResult.title,
|
|
313
|
-
usedBy: 'blog',
|
|
314
|
-
},
|
|
256
|
+
description: powertools.template(PROMPT_SOURCE, {
|
|
257
|
+
...templateVars,
|
|
258
|
+
sourceTitle: source.title,
|
|
259
|
+
}),
|
|
260
|
+
sourceContent: source.content || '',
|
|
315
261
|
};
|
|
316
262
|
}
|
|
317
263
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const description = powertools.template(PROMPT, {
|
|
322
|
-
...templateVars,
|
|
323
|
-
suggestion: suggestion,
|
|
324
|
-
});
|
|
264
|
+
const suggestion = source.type === 'brand'
|
|
265
|
+
? 'Write an article about any topic that would be relevant to our website and business (it does not have to be about our company, but it can be)'
|
|
266
|
+
: source.content;
|
|
325
267
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
// --- URL source ---
|
|
330
|
-
if (isURL(source)) {
|
|
331
|
-
const suggestion = await getURLContent(source).catch((e) => e);
|
|
332
|
-
|
|
333
|
-
if (suggestion instanceof Error) {
|
|
334
|
-
assistant.error(`resolveSource(): Error fetching URL ${source}`, suggestion);
|
|
335
|
-
if (entry.sources.includes('$brand')) {
|
|
336
|
-
return resolveSource(assistant, '$brand', entry, admin, Manager);
|
|
337
|
-
}
|
|
338
|
-
return null;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const description = powertools.template(PROMPT, {
|
|
268
|
+
return {
|
|
269
|
+
description: powertools.template(PROMPT, {
|
|
342
270
|
...templateVars,
|
|
343
271
|
suggestion: suggestion,
|
|
344
|
-
})
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// --- Text source ---
|
|
350
|
-
const description = powertools.template(PROMPT, {
|
|
351
|
-
...templateVars,
|
|
352
|
-
suggestion: source,
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
return { description, sourceContent: '' };
|
|
272
|
+
}),
|
|
273
|
+
sourceContent: '',
|
|
274
|
+
};
|
|
356
275
|
}
|
|
357
276
|
|
|
358
277
|
function randomize(array) {
|
|
359
278
|
return array.sort(() => Math.random() - 0.5);
|
|
360
279
|
}
|
|
361
|
-
|
|
362
|
-
// Re-export shared functions for backwards compatibility (newsletter + tests import from here)
|
|
363
|
-
module.exports.resolveSource = resolveSource;
|
|
364
|
-
module.exports.processFeedSource = processFeedSource;
|
|
365
|
-
module.exports.contentSourceHash = contentSourceHash;
|
|
366
|
-
module.exports.getProcessedItemIds = getProcessedItemIds;
|
|
367
|
-
module.exports.trackContentSource = trackContentSource;
|
|
368
|
-
module.exports.isURL = isURL;
|