backend-manager 5.1.2 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.claude/settings.local.json +12 -0
  2. package/CHANGELOG.md +52 -0
  3. package/CLAUDE.md +2 -1
  4. package/README.md +30 -0
  5. package/docs/common-mistakes.md +1 -0
  6. package/docs/consent.md +333 -0
  7. package/docs/marketing-campaigns.md +41 -4
  8. package/docs/testing.md +81 -0
  9. package/package.json +1 -1
  10. package/src/cli/commands/emulator.js +62 -9
  11. package/src/cli/commands/serve.js +73 -7
  12. package/src/cli/commands/test.js +65 -1
  13. package/src/cli/commands/watch.js +15 -3
  14. package/src/defaults/CLAUDE.md +7 -5
  15. package/src/manager/events/cron/daily/marketing-newsletter-generate.js +24 -8
  16. package/src/manager/helpers/user.js +29 -0
  17. package/src/manager/index.js +111 -5
  18. package/src/manager/libraries/ai/index.js +21 -0
  19. package/src/manager/libraries/ai/providers/openai.js +75 -0
  20. package/src/manager/libraries/email/data/disposable-domains.json +20 -0
  21. package/src/manager/libraries/email/generators/lib/image-host.js +58 -6
  22. package/src/manager/libraries/email/generators/lib/markdown-renderer.js +285 -0
  23. package/src/manager/libraries/email/generators/lib/structure.js +19 -2
  24. package/src/manager/libraries/email/generators/lib/svg-illustrator.js +12 -3
  25. package/src/manager/libraries/email/generators/lib/templates/classic-schema.js +9 -13
  26. package/src/manager/libraries/email/generators/lib/templates/clean.js +1 -1
  27. package/src/manager/libraries/email/generators/lib/templates/editorial.js +1 -1
  28. package/src/manager/libraries/email/generators/lib/templates/field-report.js +10 -14
  29. package/src/manager/libraries/email/generators/newsletter.js +154 -7
  30. package/src/manager/libraries/email/providers/beehiiv.js +8 -1
  31. package/src/manager/libraries/payment/processors/stripe.js +12 -0
  32. package/src/manager/libraries/payment/processors/test.js +8 -1
  33. package/src/manager/routes/admin/infer-contact/post.js +3 -2
  34. package/src/manager/routes/admin/post/post.js +3 -3
  35. package/src/manager/routes/marketing/email-preferences/post.js +165 -37
  36. package/src/manager/routes/marketing/webhook/forward/post.js +168 -0
  37. package/src/manager/routes/marketing/webhook/post.js +180 -0
  38. package/src/manager/routes/marketing/webhook/processors/beehiiv.js +196 -0
  39. package/src/manager/routes/marketing/webhook/processors/sendgrid.js +171 -0
  40. package/src/manager/routes/payments/cancel/post.js +2 -2
  41. package/src/manager/routes/payments/cancel/processors/test.js +5 -2
  42. package/src/manager/routes/payments/intent/processors/test.js +7 -3
  43. package/src/manager/routes/payments/refund/processors/test.js +4 -1
  44. package/src/manager/routes/test/health/get.js +17 -0
  45. package/src/manager/routes/user/signup/post.js +65 -1
  46. package/src/manager/schemas/marketing/email-preferences/post.js +8 -4
  47. package/src/manager/schemas/marketing/webhook/forward/post.js +8 -0
  48. package/src/manager/schemas/marketing/webhook/post.js +7 -0
  49. package/src/manager/schemas/payments/cancel/post.js +5 -0
  50. package/src/manager/schemas/user/signup/post.js +5 -0
  51. package/src/test/run-tests.js +30 -0
  52. package/src/test/runner.js +72 -26
  53. package/src/test/test-accounts.js +94 -12
  54. package/src/test/utils/http-client.js +4 -3
  55. package/src/test/utils/test-mode-file.js +192 -0
  56. package/test/events/payments/journey-payments-cancel-endpoint.js +3 -12
  57. package/test/events/payments/journey-payments-cancel.js +4 -5
  58. package/test/events/payments/journey-payments-failure.js +0 -1
  59. package/test/events/payments/journey-payments-one-time-failure.js +6 -3
  60. package/test/events/payments/journey-payments-one-time.js +6 -3
  61. package/test/events/payments/journey-payments-plan-change.js +5 -5
  62. package/test/events/payments/journey-payments-refund-webhook.js +2 -3
  63. package/test/events/payments/journey-payments-suspend.js +4 -5
  64. package/test/events/payments/journey-payments-trial-cancel.js +3 -12
  65. package/test/events/payments/journey-payments-trial.js +2 -3
  66. package/test/events/payments/journey-payments-uid-resolution.js +2 -3
  67. package/test/functions/admin/database-read.js +0 -14
  68. package/test/functions/admin/database-write.js +0 -14
  69. package/test/functions/admin/firestore-query.js +0 -14
  70. package/test/functions/admin/firestore-read.js +0 -15
  71. package/test/functions/admin/firestore-write.js +0 -11
  72. package/test/functions/general/add-marketing-contact.js +16 -14
  73. package/test/helpers/email.js +1 -1
  74. package/test/helpers/infer-contact.js +3 -3
  75. package/test/helpers/user.js +241 -2
  76. package/test/helpers/webhook-forward.js +392 -0
  77. package/test/marketing/fixtures/clean.json +2 -3
  78. package/test/marketing/fixtures/editorial.json +2 -3
  79. package/test/marketing/fixtures/field-report.json +3 -4
  80. package/test/marketing/newsletter-generate.js +78 -54
  81. package/test/marketing/newsletter-templates.js +12 -33
  82. package/test/routes/admin/create-post.js +2 -2
  83. package/test/routes/admin/database.js +0 -13
  84. package/test/routes/admin/firestore-query.js +0 -13
  85. package/test/routes/admin/firestore.js +0 -14
  86. package/test/routes/admin/infer-contact.js +6 -3
  87. package/test/routes/admin/post.js +4 -2
  88. package/test/routes/marketing/contact.js +60 -26
  89. package/test/routes/marketing/email-preferences.js +145 -69
  90. package/test/routes/marketing/webhook-forward.js +54 -0
  91. package/test/routes/marketing/webhook.js +582 -0
  92. package/test/routes/payments/cancel.js +2 -7
  93. package/test/routes/payments/dispute-alert.js +0 -39
  94. package/test/routes/payments/refund.js +3 -1
  95. package/test/routes/payments/webhook.js +5 -26
  96. package/test/routes/test/usage.js +2 -2
  97. package/test/routes/user/signup.js +114 -0
package/docs/testing.md CHANGED
@@ -11,6 +11,87 @@ npx mgr test # Terminal 2 - runs tests
11
11
  npx mgr test
12
12
  ```
13
13
 
14
+ ## Test Data Cleanup — at the START of every run
15
+
16
+ **Hard rule: all LOCAL cleanup happens BEFORE the suite runs, never after.** If a previous run was killed mid-execution (Ctrl-C, OOM, emulator crash), end-of-run cleanup would never fire and the next run would inherit polluted state — broken trial-eligibility checks, leftover dispute alerts, stale webhook docs, polluted marketing-provider lists. Pre-test cleanup makes every run idempotent regardless of how the last one died.
17
+
18
+ What the runner wipes pre-test (in [src/test/test-accounts.js](../src/test/test-accounts.js) `deleteTestUsers()` and [src/test/runner.js](../src/test/runner.js) `setupAccounts()`):
19
+
20
+ 1. **`meta/stats`** doc ensured (required for on-create batch writes).
21
+ 2. **`users/_test-*`** Firebase Auth users + Firestore docs (delete).
22
+ 3. **Marketing providers** (SendGrid + Beehiiv) — leftover test contacts removed. Runs only under `TEST_EXTENDED_MODE`. Runs **before** test users are recreated so a killed run can't leave a contact pinned to an about-to-be-recreated uid.
23
+ 4. **Mixed Firestore collections** — `payments-orders`, `payments-webhooks`, `payments-intents`, `payments-disputes`, `marketing-webhooks`. Two-pass cleanup per collection:
24
+ - Pass 1 — owner-keyed: `where('owner', 'in', [...testUids])` (batched at 30 uids per `in` query).
25
+ - Pass 2 — id-keyed: any doc whose ID starts with `_test-` (catches ownerless test docs like dispute alerts and raw test webhooks).
26
+ 5. **Test-only Firestore collections** — `_test`, `_test_query` — wiped in full.
27
+ 6. **Realtime Database** — the `_test` namespace removed in full (`admin.database().ref('_test').remove()`).
28
+
29
+ ### The one exception: third-party provider cleanup runs at start AND end
30
+
31
+ The `cleanupMarketingProviders` call also fires **after** the suite completes (extended mode only). Reason: pre-run cleanup catches what a crashed previous run left behind, but during a normal-completion run we'd still leak real SendGrid/Beehiiv contacts until the NEXT run cleaned them up. By running cleanup post-suite too, each extended-mode run leaves the provider lists in the same state it found them.
32
+
33
+ This is **specifically** for third-party state that lives outside the local emulator. **Do not use trailing cleanup for Firestore or Auth data** — those follow the start-only rule because we control the local state and pre-run cleanup is enough.
34
+
35
+ ### When adding a new test that writes data
36
+
37
+ | If your test writes to... | Then... |
38
+ |---|---|
39
+ | A new Firestore collection (mixed test + prod data) | Add the collection name to `testDataCollections` in `src/test/test-accounts.js`. |
40
+ | A new Firestore collection (test-only data) | Add it to `testOnlyCollections` in `src/test/test-accounts.js`. |
41
+ | Realtime Database | Use a path under `_test/...` — already wiped pre-test. |
42
+ | Anywhere else | Use the `_test-` doc-id prefix so id-keyed pass-2 catches it. |
43
+
44
+ ### Within-run state isolation is different
45
+
46
+ Per-test cleanup is still appropriate when a test sets up DB state that would pollute a **later test in the same run** — e.g. trial-eligibility's `try/finally` that removes the fake `payments-orders/_test-trial-eligibility-*` doc so the next sibling test sees a clean slate. Those stay in the test. They are *intra-run* state management, not *next-run* cleanup, and the distinction matters.
47
+
48
+ The rule: **never put cleanup at the END of a test file or suite for the purpose of preparing the next run** — for LOCAL state. If a test cleans up local Firestore/Auth data only to "leave no trace," that cleanup belongs in the runner's pre-test phase instead. Add the collection/namespace to the runner's wipe list and remove the trailing cleanup step. The third-party provider exception (see above) lives in the runner's post-suite hook, not in individual tests.
49
+
50
+ ## Extended Mode (`TEST_EXTENDED_MODE`)
51
+
52
+ Several routes/handlers skip external API calls (SendGrid, Beehiiv, Stripe webhooks, dispute handlers, marketing libraries) when `process.env.TEST_EXTENDED_MODE` is unset, so unit tests don't fire real emails or webhook side effects. Set the flag to opt **in** to those side effects for a full end-to-end run.
53
+
54
+ **Live sync — no env coordination across terminals.** The flag flows automatically from the test command to the running emulator via a small shared state file at `<projectRoot>/.temp/test-mode.json`. The test command writes the file pre-flight; the emulator's function workers watch it via `fs.watch` and mutate their own `process.env.TEST_EXTENDED_MODE` in place. Effect: you only need to set the flag on **the test command**. The emulator follows.
55
+
56
+ ```bash
57
+ # Terminal 1 — start once, leave running. NO flag needed.
58
+ npx mgr emulator
59
+
60
+ # Terminal 2 — toggle freely between runs:
61
+ TEST_EXTENDED_MODE=true npx mgr test ... # runs in extended mode
62
+ npx mgr test ... # runs in normal mode
63
+ TEST_EXTENDED_MODE=true npx mgr test ... # back to extended
64
+ ```
65
+
66
+ The emulator log shows each flip, e.g.:
67
+
68
+ ```
69
+ [test-mode] resolved TEST_EXTENDED_MODE=false (file present) ← worker boot
70
+ [test-mode] flip TEST_EXTENDED_MODE: (unset) → true ← test --extended fired
71
+ [test-mode] flip TEST_EXTENDED_MODE: true → (unset) ← test (no flag) fired
72
+ ```
73
+
74
+ The test command also confirms the mode in its own output (`Test mode: EXTENDED (real APIs)` pre-flight, `Mode: EXTENDED (real APIs)` after the health check). The runner's old "TEST_EXTENDED_MODE mismatch" warning is gone — mismatch is impossible by construction.
75
+
76
+ **Allowlist.** Only env vars listed in `SYNCED_ENV_KEYS` (`src/test/utils/test-mode-file.js`) flow through. Today: `TEST_EXTENDED_MODE`. Add a key there to make a new env var live-syncable across terminals. The allowlist exists to prevent accidentally syncing process-specific vars (`FIRESTORE_EMULATOR_HOST`) or sensitive ones (API keys).
77
+
78
+ **Preferred flow: set the flag on the test command.** Every `npx mgr test` invocation overwrites the shared state file with whatever flags it was called with, and the emulator follows live. This is the recommended pattern — start the emulator once with no flag, leave it running, control the mode from your test invocations.
79
+
80
+ ```bash
81
+ # Recommended
82
+ npx mgr emulator # boots in normal mode
83
+ TEST_EXTENDED_MODE=true npx mgr test ... # flips emulator to extended
84
+ npx mgr test ... # flips emulator back to normal
85
+ ```
86
+
87
+ **Also supported: set the flag on the emulator command.** This still works as a boot default — the emulator command writes the file with whatever it was started with, so the very first test run (before any `npx mgr test` overrides it) sees that mode. Useful if you want to inspect the emulator in a particular mode before firing any tests, or if you script the emulator boot from CI. Just know that the next test command overrides whatever you set here.
88
+
89
+ ```bash
90
+ # Also works (boot default — overridden by next test command)
91
+ TEST_EXTENDED_MODE=true npx mgr emulator # boots in extended mode
92
+ npx mgr test ... # ← this still flips it back to normal
93
+ ```
94
+
14
95
  ## Log Files
15
96
 
16
97
  BEM CLI commands automatically save all output to log files in `functions/` while still streaming to the console:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-manager",
3
- "version": "5.1.2",
3
+ "version": "5.2.0",
4
4
  "description": "Quick tools for developing Firebase functions",
5
5
  "main": "src/manager/index.js",
6
6
  "bin": {
@@ -8,16 +8,33 @@ const powertools = require('node-powertools');
8
8
  const WatchCommand = require('./watch');
9
9
  const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
10
10
  const { EXTENDED_MODE_WARNING } = require('../../test/utils/extended-mode-warning');
11
+ const { writeTestMode, captureSyncedEnv } = require('../../test/utils/test-mode-file');
11
12
 
12
13
  class EmulatorCommand extends BaseCommand {
13
14
  async execute() {
14
15
  this.log(chalk.cyan('\n Starting Firebase emulator (keep-alive mode)...\n'));
15
16
  this.log(chalk.gray(' Emulator will stay running until you press Ctrl+C\n'));
16
17
 
17
- // Warn if TEST_EXTENDED_MODE is enabled
18
+ // Boot-time: seed the shared state file with whatever this emulator was
19
+ // started with. Two flows are supported:
20
+ // - Recommended: start emulator without the flag, set TEST_EXTENDED_MODE
21
+ // on `npx mgr test` instead. The test command writes the file; the
22
+ // emulator's function workers watch it and flip live.
23
+ // - Also supported: start emulator with TEST_EXTENDED_MODE=true. We
24
+ // write the file here as a boot default. Useful for inspecting the
25
+ // emulator before any tests fire. Note: the next `npx mgr test`
26
+ // overwrites the file regardless of how the emulator booted.
27
+ {
28
+ const projectDir = this.main.firebaseProjectPath;
29
+ const envSubset = captureSyncedEnv(process.env);
30
+ writeTestMode(projectDir, envSubset);
31
+ }
32
+
33
+ // Show the standard warning if the emulator boots in extended mode.
18
34
  if (process.env.TEST_EXTENDED_MODE) {
19
35
  this.log(chalk.yellow.bold(`\n ${EXTENDED_MODE_WARNING[0]}`));
20
36
  EXTENDED_MODE_WARNING.slice(1).forEach((line) => this.log(chalk.yellow(` ${line}`)));
37
+ this.log(chalk.gray(` (Tip: you can also flip mode per-run by setting TEST_EXTENDED_MODE on \`npx mgr test\`.)`));
21
38
  this.log('');
22
39
  }
23
40
 
@@ -65,15 +82,47 @@ class EmulatorCommand extends BaseCommand {
65
82
  : 'BEM_TESTING=true';
66
83
  const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
67
84
 
68
- // Set up log file in the project directory
85
+ // Set up log file in the project directory.
86
+ // We use a mutable `currentStream` so the test command can request a fresh log
87
+ // by touching emulator.log.reset — the watcher below detects it, closes the
88
+ // current stream, reopens with flags: 'w' (truncating cleanly from our process'
89
+ // perspective, no sparse-file issue), and deletes the sentinel.
69
90
  const logPath = path.join(projectDir, 'functions', 'emulator.log');
70
- const logStream = fs.createWriteStream(logPath, { flags: 'w' });
91
+ const resetSentinelPath = `${logPath}.reset`;
71
92
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
72
93
 
94
+ let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
95
+
96
+ function writeToLog(data) {
97
+ if (currentStream && !currentStream.destroyed) {
98
+ currentStream.write(stripAnsi(data.toString()));
99
+ }
100
+ }
101
+
102
+ // Clean up any stale sentinel from a prior crashed emulator run
103
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
104
+
105
+ // Watch for the test command's request to roll the log.
106
+ // Poll every 500ms — cheap, no fs.watch quirks across platforms.
107
+ const resetWatcher = setInterval(() => {
108
+ if (!fs.existsSync(resetSentinelPath)) {
109
+ return;
110
+ }
111
+
112
+ try {
113
+ const oldStream = currentStream;
114
+ currentStream = fs.createWriteStream(logPath, { flags: 'w' });
115
+ oldStream.end();
116
+ fs.unlinkSync(resetSentinelPath);
117
+ } catch (e) {
118
+ // Best-effort. If reset fails the test still runs, the log just won't be fresh.
119
+ }
120
+ }, 500);
121
+
73
122
  // Write pre-emulator info to log file
74
123
  if (process.env.TEST_EXTENDED_MODE) {
75
- EXTENDED_MODE_WARNING.forEach((line) => logStream.write(`${line}\n`));
76
- logStream.write('\n');
124
+ EXTENDED_MODE_WARNING.forEach((line) => writeToLog(`${line}\n`));
125
+ writeToLog('\n');
77
126
  }
78
127
 
79
128
  this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
@@ -89,18 +138,22 @@ class EmulatorCommand extends BaseCommand {
89
138
  // Tee stdout to both console and log file (strip ANSI codes for clean log)
90
139
  child.stdout.on('data', (data) => {
91
140
  process.stdout.write(data);
92
- logStream.write(stripAnsi(data.toString()));
141
+ writeToLog(data);
93
142
  });
94
143
 
95
144
  // Tee stderr to both console and log file (strip ANSI codes for clean log)
96
145
  child.stderr.on('data', (data) => {
97
146
  process.stderr.write(data);
98
- logStream.write(stripAnsi(data.toString()));
147
+ writeToLog(data);
99
148
  });
100
149
 
101
- // Clean up log stream when child exits
150
+ // Clean up log stream + watcher when child exits
102
151
  child.on('close', () => {
103
- logStream.end();
152
+ clearInterval(resetWatcher);
153
+ if (currentStream && !currentStream.destroyed) {
154
+ currentStream.end();
155
+ }
156
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
104
157
  });
105
158
  });
106
159
  }
@@ -25,11 +25,63 @@ class ServeCommand extends BaseCommand {
25
25
  // Start Stripe webhook forwarding in background
26
26
  this.startStripeWebhookForwarding();
27
27
 
28
- // Set up log file in the project directory
28
+ // Set up log file in the project directory.
29
+ // Mirrors the emulator.js pattern: the file is truncated on boot and on every
30
+ // hot reload. Two reset signals are honored:
31
+ // 1. Sentinel file (serve.log.reset) — used by the BEM watcher when source
32
+ // changes in backend-manager itself trigger a reload.
33
+ // 2. Reload marker on stdout (`Using node@22 from host.`) — catches reloads
34
+ // triggered by firebase serve's own internal watcher (any change inside
35
+ // the consumer's functions/ directory). MUST be the line firebase-tools
36
+ // prints at the START of each reload cycle, not somewhere in the middle.
37
+ // If we rolled mid-cycle (e.g. on "Loaded functions definitions"), the
38
+ // tail of the reload sequence still gets captured into the fresh log;
39
+ // but firebase serve sometimes only emits the trailing function-initialized
40
+ // lines on the first cycle (subsequent cycles route those elsewhere), so
41
+ // we'd end up with a near-empty log. Rolling at the START of the cycle
42
+ // lets us capture whatever firebase-tools does emit, complete-or-not.
29
43
  const logPath = path.join(projectDir, 'functions', 'serve.log');
30
- const logStream = fs.createWriteStream(logPath, { flags: 'w' });
44
+ const resetSentinelPath = `${logPath}.reset`;
45
+ // Match any node version: "Using node@22 from host.", "Using node@20 from host.", etc.
46
+ const RELOAD_MARKER = /Using node@\d+ from host\./;
31
47
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
32
48
 
49
+ let currentStream = fs.createWriteStream(logPath, { flags: 'w' });
50
+ let reloadCount = 0; // skip rolling on the first marker (initial boot, not a reload)
51
+
52
+ function rollLog() {
53
+ try {
54
+ const oldStream = currentStream;
55
+ currentStream = fs.createWriteStream(logPath, { flags: 'w' });
56
+ oldStream.end();
57
+ } catch (e) {
58
+ // Best-effort. If roll fails, serve keeps running with the existing stream.
59
+ }
60
+ }
61
+
62
+ function writeToLog(data) {
63
+ if (currentStream && !currentStream.destroyed) {
64
+ currentStream.write(stripAnsi(data.toString()));
65
+ }
66
+ }
67
+
68
+ // Clean up any stale sentinel from a prior crashed serve run
69
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
70
+
71
+ // Poll every 500ms for the reset sentinel — cheap, no fs.watch quirks
72
+ const resetWatcher = setInterval(() => {
73
+ if (!fs.existsSync(resetSentinelPath)) {
74
+ return;
75
+ }
76
+
77
+ try {
78
+ rollLog();
79
+ fs.unlinkSync(resetSentinelPath);
80
+ } catch (e) {
81
+ // Best-effort.
82
+ }
83
+ }, 500);
84
+
33
85
  this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
34
86
 
35
87
  // Execute with tee to log file
@@ -42,21 +94,35 @@ class ServeCommand extends BaseCommand {
42
94
  env: { ...process.env, FORCE_COLOR: '1' },
43
95
  },
44
96
  }, (child) => {
45
- // Tee stdout to both console and log file (strip ANSI codes for clean log)
97
+ // Tee stdout to both console and log file (strip ANSI codes for clean log).
98
+ // Watch each chunk for the reload marker — when seen (after the initial boot),
99
+ // roll the log BEFORE writing this chunk so the marker becomes the first
100
+ // line of the fresh file.
46
101
  child.stdout.on('data', (data) => {
47
102
  process.stdout.write(data);
48
- logStream.write(stripAnsi(data.toString()));
103
+ const text = data.toString();
104
+ if (RELOAD_MARKER.test(text)) {
105
+ reloadCount++;
106
+ if (reloadCount > 1) {
107
+ rollLog();
108
+ }
109
+ }
110
+ writeToLog(data);
49
111
  });
50
112
 
51
113
  // Tee stderr to both console and log file (strip ANSI codes for clean log)
52
114
  child.stderr.on('data', (data) => {
53
115
  process.stderr.write(data);
54
- logStream.write(stripAnsi(data.toString()));
116
+ writeToLog(data);
55
117
  });
56
118
 
57
- // Clean up log stream when child exits
119
+ // Clean up log stream + watcher when child exits
58
120
  child.on('close', () => {
59
- logStream.end();
121
+ clearInterval(resetWatcher);
122
+ if (currentStream && !currentStream.destroyed) {
123
+ currentStream.end();
124
+ }
125
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
60
126
  });
61
127
  });
62
128
  } catch (error) {
@@ -6,6 +6,7 @@ const jetpack = require('fs-jetpack');
6
6
  const JSON5 = require('json5');
7
7
  const powertools = require('node-powertools');
8
8
  const { DEFAULT_EMULATOR_PORTS } = require('./setup-tests/emulator-config');
9
+ const { writeTestMode, captureSyncedEnv, SYNCED_ENV_KEYS } = require('../../test/utils/test-mode-file');
9
10
  const EmulatorCommand = require('./emulator');
10
11
 
11
12
  class TestCommand extends BaseCommand {
@@ -20,6 +21,23 @@ class TestCommand extends BaseCommand {
20
21
  const projectDir = self.firebaseProjectPath;
21
22
  const functionsDir = path.join(projectDir, 'functions');
22
23
 
24
+ // Pre-flight: write the allowlisted env subset to a shared state file
25
+ // (`<projectDir>/.temp/test-mode.json`). The running emulator watches this
26
+ // file and mutates its own `process.env` to match, eliminating the need
27
+ // to coordinate env vars across both terminals. The test command is the
28
+ // authoritative writer — whatever you pass here becomes the live mode
29
+ // within ~50ms.
30
+ //
31
+ // Allowlist lives in src/test/utils/test-mode-file.js (SYNCED_ENV_KEYS).
32
+ // Today: just TEST_EXTENDED_MODE. Add more keys there to make them
33
+ // live-syncable.
34
+ {
35
+ const envSubset = captureSyncedEnv(process.env);
36
+ writeTestMode(projectDir, envSubset);
37
+ const extended = !!process.env.TEST_EXTENDED_MODE;
38
+ this.log(chalk.gray(` Test mode: ${extended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
39
+ }
40
+
23
41
  // Load emulator ports from firebase.json
24
42
  const emulatorPorts = this.loadEmulatorPorts(projectDir);
25
43
 
@@ -33,7 +51,7 @@ class TestCommand extends BaseCommand {
33
51
  // Use hosting URL for all API requests (rewrites to bm_api function)
34
52
  const testConfig = {
35
53
  ...projectConfig,
36
- hostingUrl: `http://127.0.0.1:${emulatorPorts.hosting}`,
54
+ apiUrl: `http://127.0.0.1:${emulatorPorts.hosting}`,
37
55
  projectDir,
38
56
  testPaths,
39
57
  emulatorPorts,
@@ -167,12 +185,58 @@ class TestCommand extends BaseCommand {
167
185
  return this.isPortInUse(emulatorPorts.functions);
168
186
  }
169
187
 
188
+ /**
189
+ * Signal the running emulator process to roll emulator.log.
190
+ *
191
+ * Mechanism: write a sentinel file at emulator.log.reset. The emulator command
192
+ * (src/cli/commands/emulator.js) polls for it and, on detection, closes its
193
+ * current write stream and reopens with flags: 'w' (truncating cleanly from its
194
+ * own perspective — avoids the sparse-file problem caused by external truncation).
195
+ *
196
+ * Waits up to 2s for the sentinel to be consumed. If it's still there after 2s
197
+ * the emulator isn't watching (probably running an older BEM or started outside
198
+ * `npx mgr emulator`); we delete the sentinel and proceed — tests still run, the
199
+ * log just won't be reset for this run.
200
+ */
201
+ async requestEmulatorLogReset(projectDir) {
202
+ const logPath = path.join(projectDir, 'functions', 'emulator.log');
203
+ const sentinelPath = `${logPath}.reset`;
204
+
205
+ try {
206
+ fs.writeFileSync(sentinelPath, '');
207
+ } catch (e) {
208
+ return; // Can't write — skip, not fatal
209
+ }
210
+
211
+ // Poll for the emulator to consume the sentinel (it deletes the file when done)
212
+ const maxWaitMs = 2000;
213
+ const pollIntervalMs = 100;
214
+ const start = Date.now();
215
+
216
+ while (Date.now() - start < maxWaitMs) {
217
+ if (!fs.existsSync(sentinelPath)) {
218
+ return; // Emulator picked it up and rolled the log
219
+ }
220
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
221
+ }
222
+
223
+ // Timed out — emulator didn't see the sentinel. Clean up so we don't leave it behind.
224
+ try { fs.unlinkSync(sentinelPath); } catch (e) { /* ok */ }
225
+ }
226
+
170
227
  /**
171
228
  * Run tests directly (emulator already running)
172
229
  */
173
230
  async runTestsDirectly(testCommand, functionsDir, emulatorPorts) {
174
231
  const projectDir = this.main.firebaseProjectPath;
175
232
 
233
+ // Ask the running emulator process to roll emulator.log so this test run gets a
234
+ // clean slate. We touch a sentinel file the emulator polls for (every ~500ms) and
235
+ // wait briefly for it to be consumed. If the emulator isn't watching (older BEM
236
+ // version, or not started via `npx mgr emulator`), we time out silently — the log
237
+ // just won't be fresh, tests still run normally.
238
+ await this.requestEmulatorLogReset(projectDir);
239
+
176
240
  this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
177
241
  this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
178
242
  this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
@@ -53,13 +53,21 @@ class WatchCommand extends BaseCommand {
53
53
  // So we must: 1) ensure file exists, 2) wait for FS to settle, 3) write new content
54
54
  // --on-change-only: only run exec when files change, not on initial startup
55
55
  // --delay 1: debounce multiple rapid changes into one trigger
56
+ //
57
+ // The exec also drops <log>.reset sentinels so the parent serve/emulator command
58
+ // rolls its log file cleanly on every hot reload (mirrors the emulator log-roll
59
+ // pattern used by the test runner). Sentinels are best-effort — if a parent
60
+ // command isn't watching, the file is harmless and gets cleaned up by the next
61
+ // boot's stale-sentinel sweep.
56
62
  const triggerFile = config.triggerFile;
63
+ const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
64
+ const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
57
65
  const nodemon = spawn(nodemonPath, [
58
66
  '--on-change-only',
59
67
  '--delay', '1',
60
68
  '--watch', config.bemSrcDir,
61
69
  '--ext', 'js,json',
62
- '--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now())" && echo " [BEM] Triggered hot reload"`,
70
+ '--exec', `node -e "var f='${triggerFile}',fs=require('fs');if(!fs.existsSync(f)){fs.writeFileSync(f,'// init');require('child_process').execSync('sleep 0.1');}fs.writeFileSync(f,'// '+Date.now());try{fs.writeFileSync('${serveLogResetPath}','');}catch(e){}try{fs.writeFileSync('${emulatorLogResetPath}','');}catch(e){}" && echo " [BEM] Triggered hot reload"`,
63
71
  ], {
64
72
  stdio: 'inherit',
65
73
  detached: false,
@@ -93,11 +101,15 @@ class WatchCommand extends BaseCommand {
93
101
  jetpack.write(config.triggerFile, `// BEM reload trigger\n`);
94
102
  }
95
103
 
96
- // Use nodemon to watch the BEM src directory and touch the trigger file on changes
104
+ // Use nodemon to watch the BEM src directory and touch the trigger file on changes.
105
+ // Also drop <log>.reset sentinels so any sibling serve/emulator command rolls its
106
+ // log file on each reload (mirrors the test runner's log-roll pattern).
107
+ const serveLogResetPath = path.join(config.functionsDir, 'serve.log.reset');
108
+ const emulatorLogResetPath = path.join(config.functionsDir, 'emulator.log.reset');
97
109
  const nodemon = spawn(nodemonPath, [
98
110
  '--watch', config.bemSrcDir,
99
111
  '--ext', 'js,json',
100
- '--exec', `touch "${config.triggerFile}" && echo " → Triggered hot reload"`,
112
+ '--exec', `touch "${config.triggerFile}" "${serveLogResetPath}" "${emulatorLogResetPath}" && echo " → Triggered hot reload"`,
101
113
  ], {
102
114
  stdio: 'inherit',
103
115
  cwd: config.bemDir,
@@ -16,11 +16,13 @@ This project consumes **Backend Manager** (BEM) — a comprehensive framework fo
16
16
 
17
17
  ```bash
18
18
  cd functions
19
- npx mgr setup # validate config + scaffold defaults + run checks
20
- npx mgr emulator # start Firebase emulators (auth/firestore/functions/database/storage)
21
- npx mgr watch # auto-reload functions on file change
22
- npx mgr deploy # deploy to Firebase
23
- npx mgr logs # tail Cloud Functions logs
19
+ npx mgr setup # validate config + scaffold defaults + run checks
20
+ npx mgr emulator # start Firebase emulators (auth/firestore/functions/database/storage)
21
+ npx mgr watch # auto-reload functions on file change
22
+ npx mgr deploy # deploy to Firebase
23
+ npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream)
24
+ npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
25
+ npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
24
26
  ```
25
27
 
26
28
  All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
@@ -94,14 +94,25 @@ module.exports = async ({ Manager, assistant, libraries }) => {
94
94
  const nowISO = new Date().toISOString();
95
95
  const nowUNIX = Math.round(Date.now() / 1000);
96
96
 
97
- // Strip non-serializable fields out of the generator's return before
98
- // writing to Firestore. `images` is an array of PNG Buffers (not safe
99
- // to persist) and `mjml` is a raw template string that pollutes the doc.
100
- const { images: _images, mjml: _mjml, assets, meta, ...campaignSettings } = generated;
97
+ // Strip non-serializable / oversized fields out of the generator's return
98
+ // before writing to Firestore.
99
+ // images: Buffer[] not safe to persist
100
+ // mjml: raw template string pollutes the doc, available in the GH archive
101
+ // structure: full JSON dump (5-10kb) — available via assets.markdownUrl + assets.htmlUrl
102
+ // contentMarkdown: large markdown blob — available via assets.markdownUrl
103
+ const {
104
+ images: _images,
105
+ mjml: _mjml,
106
+ structure: _structure,
107
+ contentMarkdown: _contentMarkdown,
108
+ assets,
109
+ meta,
110
+ ...campaignSettings
111
+ } = generated;
101
112
 
102
113
  await admin.firestore().doc(`marketing-campaigns/${newId}`).set({
103
114
  settings: campaignSettings,
104
- assets: assets || null, // { folderUrl, htmlUrl, imageUrls, campaignId } or null
115
+ assets: assets || null, // { folderUrl, htmlUrl, markdownUrl, summaryUrl, imageUrls, beehiivPostId, tags, campaignId }
105
116
  meta: meta || null, // tokens, cost, durations, source scores
106
117
  type,
107
118
  sendAt: data.sendAt,
@@ -115,11 +126,16 @@ module.exports = async ({ Manager, assistant, libraries }) => {
115
126
 
116
127
  assistant.log(`Created campaign ${newId} from generator ${campaignId}: "${generated.subject}"`);
117
128
  if (assets?.htmlUrl) {
118
- assistant.log(` HTML: ${assets.htmlUrl}`);
119
- assistant.log(` Folder: ${assets.folderUrl}`);
129
+ assistant.log(` HTML: ${assets.htmlUrl}`);
130
+ assistant.log(` Markdown: ${assets.markdownUrl || '(none)'}`);
131
+ assistant.log(` Summary: ${assets.summaryUrl || '(none)'}`);
132
+ assistant.log(` Folder: ${assets.folderUrl}`);
120
133
  }
121
134
  if (assets?.beehiivPostId) {
122
- assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
135
+ assistant.log(` Beehiiv: draft post ${assets.beehiivPostId}`);
136
+ }
137
+ if (assets?.tags?.length) {
138
+ assistant.log(` Tags: ${assets.tags.join(', ')}`);
123
139
  }
124
140
 
125
141
  // Advance the recurring doc's sendAt to the next occurrence
@@ -147,6 +147,35 @@ const SCHEMA = {
147
147
  page: { type: 'string', default: null, nullable: true },
148
148
  },
149
149
  },
150
+ consent: {
151
+ legal: {
152
+ status: { type: 'string', default: 'revoked' },
153
+ grantedAt: {
154
+ timestamp: { type: 'string', default: null, nullable: true },
155
+ timestampUNIX: { type: 'number', default: null, nullable: true },
156
+ source: { type: 'string', default: null, nullable: true },
157
+ ip: { type: 'string', default: null, nullable: true },
158
+ text: { type: 'string', default: null, nullable: true },
159
+ },
160
+ },
161
+ marketing: {
162
+ status: { type: 'string', default: 'revoked' },
163
+ grantedAt: {
164
+ timestamp: { type: 'string', default: null, nullable: true },
165
+ timestampUNIX: { type: 'number', default: null, nullable: true },
166
+ source: { type: 'string', default: null, nullable: true },
167
+ ip: { type: 'string', default: null, nullable: true },
168
+ text: { type: 'string', default: null, nullable: true },
169
+ },
170
+ revokedAt: {
171
+ timestamp: { type: 'string', default: null, nullable: true },
172
+ timestampUNIX: { type: 'number', default: null, nullable: true },
173
+ source: { type: 'string', default: null, nullable: true },
174
+ ip: { type: 'string', default: null, nullable: true },
175
+ text: { type: 'string', default: null, nullable: true },
176
+ },
177
+ },
178
+ },
150
179
  };
151
180
 
152
181
  /**