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 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.**
@@ -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., `![alt](https://images.unsplash.com/...)`)
12
12
  2. Extracts all `![alt](url)` patterns from the body using regex
13
13
  3. Downloads each image to a tmp dir
14
- 4. **Resizes** each image in place if its long edge exceeds `IMAGE_MAX_DIMENSION` (see below)
15
- 5. Commits all images to `src/assets/images/blog/post-{id}/` on GitHub (single commit via Git Trees API)
16
- 6. **Rewrites the body** to replace external URLs with `@post/{filename}` format
17
- 7. The `@post/` prefix is resolved at Jekyll build time by `jekyll-uj-powertools` to the full path
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`), which uses Imgix-style `?w=&q=` params. 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.
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
 
@@ -1,44 +1,29 @@
1
1
  # CDP Debugging (driving a live browser)
2
2
 
3
- How to launch a browser you can CONTROL — see a site live, screenshot it, click, type, read console logs, inspect network requests — for agents (Claude via MCP/CDP) and humans. BEM has no UI of its own; reach for this when **verifying the frontend that consumes your backend** (the UJM site, a deployed app): drive the auth flow, watch the network panel for calls to your routes, read the actual request/response payloads.
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 four sister frameworks (UJM / BEM / BXM / EM) — same core section, framework-flavored. Edit all four together.
5
+ > Mirrored across the five sister frameworks (UJM / BEM / BXM / EM / WM) — same core section, framework-flavored. Edit all five together.
6
6
 
7
- ## Launching a controllable Chrome (the canonical command)
7
+ ## The browser: your Claude session owns one
8
8
 
9
- ```bash
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
- Verify it's up: `curl -s http://127.0.0.1:9223/json/version`
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
- The rules that make this work (each one learned the hard way):
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
- - **`open -gna` launches WITHOUT stealing focus.** `-g` = don't bring to foreground, `-n` = new instance (required — without it `open` just activates the already-running daily Chrome and the `--args` are ignored). Launching the Chrome binary directly ALWAYS activates the app and steals focus. Do NOT use `-j`/`--hide` — animations need a visible window; instead the three `--disable-*` flags keep timers/rAF/rendering at FULL speed while the window sits behind your work (verified: rAF at the display's native 120fps while backgrounded, focus never moved).
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
- ## Driving it
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 HTTPS.** BrowserSync serves over HTTPS (self-signed cert). Prefer `https://localhost:4000`; fall back to the machine's local network IP (e.g. `https://192.168.x.x:4000`) if localhost doesn't connect. Port 4000 by default, increments to 4001+ when multiple sites run. The exact URL 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) — read that file first, every time, before navigating.
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
- resolveSource() -- detect source type, fetch/parse if needed
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 (for $feed:, $parent sources)
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 three levels:
93
+ The blog auto-publisher prevents near-duplicate articles at two levels:
94
94
 
95
- 1. **Per-feed dedup**: `processFeedSource()` queries `content-sources` by `feedUrl` to skip items already used from that specific feed.
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. **Cross-feed dedup** (structural): Before picking a feed item, `processFeedSource()` compares each candidate's title against ALL recent titles (from any source) using `isTitleTooSimilar()` a word-overlap check with basic stemming. This catches the same news event reported by different publications (e.g. Guardian and NYT both covering Apple's price hike). Items with >= 50% significant-word overlap are skipped.
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
- 3. **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.
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
- Within a single cron run, both generated post titles AND source item titles are tracked in `runTitles` and fed into levels 2 and 3 for subsequent articles.
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
- All source types (`$feed`, `$parent`, `$brand`, URL, text) are tracked in `content-sources` after publishing.
103
+ ### Source picking + fallback hierarchy
104
104
 
105
- ### Source fallback behavior
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
- When a source fails or is exhausted, `resolveSource()` only falls back to `$brand` if `$brand` is explicitly listed in the entry's `sources` array. Otherwise it returns null, and `harvest()` tries the next source in a shuffled copy of the pool. If all sources are exhausted, that article slot is skipped.
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. Fetch sources: `GET {parentUrl}/newsletter/sources?category=X&claimFor=brandId` (atomic claim)
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: `PUT {parentUrl}/newsletter/sources` per source
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
- **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:
287
- - The failure reason
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, summary, and the full GitHub folder
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 alert is best-effort; failure to send is logged but does not block the Firestore campaign-doc write.
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, fetches sources, claims sources |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.10.2",
3
+ "version": "5.10.3",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "files": [
package/src/cli/index.js CHANGED
@@ -1,6 +1,12 @@
1
1
  const os = require('os');
2
2
  const path = require('path');
3
- const argv = require('yargs')(process.argv.slice(2)).argv;
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)
@@ -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
- * All feed/parent sources are tracked in Firestore (`content-sources`) so the
17
- * same article is never processed twice. When a feed is unreachable or
18
- * exhausted, harvest() tries the next source in the pool. Only falls
19
- * back to $brand if '$brand' is explicitly listed in the entry's sources.
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
- FEED_PREFIX,
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 = randomize(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
- const allKnownTitles = [...recentTitles, ...runTitles];
130
+ assistant.log(`harvest(): Processing ${index + 1}/${entry.quantity}`);
139
131
 
140
- let resolved = null;
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
- resolved = await resolveSource(assistant, source, entry, admin, Manager).catch((e) => {
146
- assistant.error('harvest(): Error resolving source', e);
147
- return null;
148
- });
149
- if (resolved) { break; }
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 (!resolved) {
153
- assistant.log('harvest(): All sources exhausted for this article, skipping');
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', resolved);
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: resolved.trackingData?.url || null,
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 (resolved.trackingData?.itemTitle) {
214
- runTitles.push(resolved.trackingData.itemTitle);
208
+ if (source.trackingData?.itemTitle) {
209
+ runTitles.push(source.trackingData.itemTitle);
215
210
  }
216
211
 
217
- if (!resolved.trackingData) {
218
- resolved.trackingData = {
219
- url: uploadedPost.slug || `brand-${postId}`,
220
- origin: '$brand',
221
- usedBy: 'blog',
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
- ...resolved.trackingData,
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
- async function resolveSource(assistant, source, entry, admin, Manager) {
240
- const date = moment().format('MMMM YYYY');
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: 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
- // --- $feed: source ---
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
- sourceContent: parentResult.content || parentResult.summary || '',
308
- trackingData: {
309
- url: parentResult.url || parentResult.id,
310
- origin: '$parent',
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
- // --- $brand source ---
319
- if (source === '$brand') {
320
- const suggestion = '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)';
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
- return { description, sourceContent: '' };
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
- return { description, sourceContent: '' };
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;