backend-manager 5.2.3 → 5.2.6

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 (50) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CLAUDE.md +3 -3
  3. package/TODO-CANCEL-EMAIL-MISSING-ORDER-ID.md +159 -0
  4. package/TODO-WEBHOOK-KEY-LEGACY-REMOVAL.md +15 -0
  5. package/TODO-WEBHOOK-KEY-UPGRADE.md +138 -0
  6. package/docs/consent.md +5 -10
  7. package/docs/sanitization.md +32 -24
  8. package/docs/schemas.md +1 -1
  9. package/docs/stripe-webhook-forwarding.md +2 -2
  10. package/docs/testing.md +8 -7
  11. package/package.json +1 -1
  12. package/scripts/test-helper-providers.js +162 -0
  13. package/src/cli/commands/base-command.js +5 -5
  14. package/src/cli/commands/emulator.js +201 -54
  15. package/src/cli/commands/test.js +80 -9
  16. package/src/manager/events/cron/daily/ghostii-auto-publisher.js +2 -2
  17. package/src/manager/events/firestore/payments-webhooks/analytics.js +2 -2
  18. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  19. package/src/manager/helpers/analytics.js +1 -1
  20. package/src/manager/helpers/middleware.js +7 -4
  21. package/src/manager/helpers/utilities.js +31 -0
  22. package/src/manager/libraries/email/generators/newsletter.js +2 -2
  23. package/src/manager/libraries/email/providers/beehiiv.js +69 -27
  24. package/src/manager/libraries/email/providers/sendgrid.js +38 -12
  25. package/src/manager/libraries/email/validation.js +1 -1
  26. package/src/manager/libraries/infer-contact.js +1 -1
  27. package/src/manager/routes/general/email/post.js +4 -2
  28. package/src/manager/routes/marketing/email-preferences/post.js +2 -2
  29. package/src/manager/routes/payments/dispute-alert/post.js +3 -3
  30. package/src/manager/routes/payments/intent/processors/test.js +2 -2
  31. package/src/manager/routes/payments/webhook/post.js +2 -2
  32. package/src/manager/routes/user/delete.js +1 -1
  33. package/src/manager/routes/user/oauth2/providers/discord.js +1 -1
  34. package/src/manager/routes/user/oauth2/providers/google.js +1 -1
  35. package/src/test/runner.js +7 -31
  36. package/src/test/test-accounts.js +8 -63
  37. package/src/test/utils/http-client.js +1 -0
  38. package/test/events/payments/journey-payments-cancel.js +4 -4
  39. package/test/events/payments/journey-payments-failure.js +2 -2
  40. package/test/events/payments/journey-payments-legacy-product.js +1 -1
  41. package/test/events/payments/journey-payments-one-time-failure.js +1 -1
  42. package/test/events/payments/journey-payments-plan-change.js +1 -1
  43. package/test/events/payments/journey-payments-refund-webhook.js +4 -4
  44. package/test/events/payments/journey-payments-suspend.js +4 -4
  45. package/test/events/payments/journey-payments-trial.js +2 -2
  46. package/test/events/payments/journey-payments-uid-resolution.js +1 -1
  47. package/test/marketing/consent-lifecycle.js +255 -0
  48. package/test/routes/payments/dispute-alert.js +13 -13
  49. package/test/routes/payments/webhook.js +3 -3
  50. /package/src/manager/routes/general/email/templates/{download-app-link.js → general/download-app-link.js} +0 -0
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Test Helper — direct provider lookups + removals via API
5
+ *
6
+ * Lets the live-test checklist verify SendGrid + Beehiiv state without
7
+ * clicking around in dashboards. Reuses the same provider helpers that
8
+ * BEM uses in production (findContact / removeContact).
9
+ *
10
+ * Usage (run from a consumer project's functions/ dir):
11
+ *
12
+ * node ../../../backend-manager/scripts/test-helper-providers.js find user@example.com
13
+ * node ../../../backend-manager/scripts/test-helper-providers.js purge user@example.com
14
+ *
15
+ * Or symlink: `ln -s ../../../backend-manager/scripts/test-helper-providers.js ./prov`
16
+ * then: `node ./prov find user@example.com`
17
+ *
18
+ * - find: prints whether the contact exists in SendGrid + Beehiiv (and the data shape).
19
+ * - purge: removes the contact from BOTH providers (idempotent; safe if absent).
20
+ *
21
+ * The script picks up:
22
+ * - SENDGRID_API_KEY, BEEHIIV_API_KEY from <cwd>/.env (functions/.env)
23
+ * - marketing.sendgrid.listId, marketing.beehiiv.publicationId from <cwd>/backend-manager-config.json
24
+ * - service-account from <cwd>/service-account.json
25
+ *
26
+ * Exit codes: 0 ok, 1 usage error, 2 provider error.
27
+ */
28
+
29
+ const path = require('path');
30
+ const fs = require('fs');
31
+
32
+ const cwd = process.cwd();
33
+ const envPath = path.join(cwd, '.env');
34
+ const configPath = path.join(cwd, 'backend-manager-config.json');
35
+ const serviceAccountPath = path.join(cwd, 'service-account.json');
36
+
37
+ // --- sanity checks ---
38
+ if (!fs.existsSync(envPath)) {
39
+ console.error(`✗ Missing ${envPath} — run from the consumer's functions/ dir`);
40
+ process.exit(1);
41
+ }
42
+ if (!fs.existsSync(configPath)) {
43
+ console.error(`✗ Missing ${configPath} — run from the consumer's functions/ dir`);
44
+ process.exit(1);
45
+ }
46
+ if (!fs.existsSync(serviceAccountPath)) {
47
+ console.error(`✗ Missing ${serviceAccountPath} — run from the consumer's functions/ dir`);
48
+ process.exit(1);
49
+ }
50
+
51
+ // --- parse args ---
52
+ const [, , cmd, email] = process.argv;
53
+ const VALID_CMDS = ['find', 'purge'];
54
+
55
+ if (!cmd || !VALID_CMDS.includes(cmd) || !email) {
56
+ console.error('Usage:');
57
+ console.error(' node test-helper-providers.js find <email>');
58
+ console.error(' node test-helper-providers.js purge <email>');
59
+ process.exit(1);
60
+ }
61
+
62
+ // --- bootstrap env (dotenv style, minimal) ---
63
+ require('dotenv').config({ path: envPath });
64
+
65
+ if (!process.env.SENDGRID_API_KEY) {
66
+ console.error('✗ SENDGRID_API_KEY not set in .env');
67
+ process.exit(1);
68
+ }
69
+ if (!process.env.BEEHIIV_API_KEY) {
70
+ console.error('✗ BEEHIIV_API_KEY not set in .env');
71
+ process.exit(1);
72
+ }
73
+
74
+ // --- bootstrap Manager so providers can read Manager.config.marketing.* ---
75
+ // The providers require '../../../index.js' which is the Manager singleton.
76
+ // We need to load BEM from the consumer's node_modules (not the BEM repo's own src)
77
+ // so it picks up the consumer's config + service account.
78
+ let Manager;
79
+ try {
80
+ const BackendManager = require(path.join(cwd, 'node_modules', 'backend-manager'));
81
+ Manager = (new BackendManager()).init({}, { setupFunctionsLegacy: false, log: false });
82
+ } catch (e) {
83
+ console.error('✗ Failed to bootstrap Manager from consumer node_modules:', e.message);
84
+ process.exit(2);
85
+ }
86
+
87
+ // --- load providers via the BEM Manager.libraries surface (preferred) or direct path ---
88
+ const sendgridProviderPath = path.join(cwd, 'node_modules', 'backend-manager', 'src', 'manager', 'libraries', 'email', 'providers', 'sendgrid.js');
89
+ const beehiivProviderPath = path.join(cwd, 'node_modules', 'backend-manager', 'src', 'manager', 'libraries', 'email', 'providers', 'beehiiv.js');
90
+
91
+ const sendgrid = require(sendgridProviderPath);
92
+ const beehiiv = require(beehiivProviderPath);
93
+
94
+ // --- commands ---
95
+ async function find(email) {
96
+ console.log(`Looking up ${email} on both providers...`);
97
+
98
+ const [sgContact, bhContact] = await Promise.all([
99
+ sendgrid.findContact(email).catch(e => ({ _error: e.message })),
100
+ beehiiv.findContact(email).catch(e => ({ _error: e.message })),
101
+ ]);
102
+
103
+ console.log('\n── SendGrid ──');
104
+ if (sgContact?._error) {
105
+ console.log(` ✗ error: ${sgContact._error}`);
106
+ } else if (!sgContact) {
107
+ console.log(' ⊘ not found');
108
+ } else {
109
+ console.log(` ✓ found — id=${sgContact.id || '?'}, email=${sgContact.email || '?'}`);
110
+ }
111
+
112
+ console.log('\n── Beehiiv ──');
113
+ if (bhContact?._error) {
114
+ console.log(` ✗ error: ${bhContact._error}`);
115
+ } else if (!bhContact) {
116
+ console.log(' ⊘ not found');
117
+ } else {
118
+ console.log(` ✓ found — id=${bhContact.id || '?'}, email=${bhContact.email || '?'}, status=${bhContact.status || '?'}`);
119
+ }
120
+
121
+ return { sgContact, bhContact };
122
+ }
123
+
124
+ async function purge(email) {
125
+ console.log(`Removing ${email} from both providers...`);
126
+
127
+ const [sgResult, bhResult] = await Promise.all([
128
+ sendgrid.removeContact(email).catch(e => ({ _error: e.message })),
129
+ beehiiv.removeContact(email).catch(e => ({ _error: e.message })),
130
+ ]);
131
+
132
+ console.log('\n── SendGrid ──');
133
+ if (sgResult?._error) {
134
+ console.log(` ✗ error: ${sgResult._error}`);
135
+ } else {
136
+ console.log(` ✓ ${JSON.stringify(sgResult)}`);
137
+ }
138
+
139
+ console.log('\n── Beehiiv ──');
140
+ if (bhResult?._error) {
141
+ console.log(` ✗ error: ${bhResult._error}`);
142
+ } else {
143
+ console.log(` ✓ ${JSON.stringify(bhResult)}`);
144
+ }
145
+
146
+ return { sgResult, bhResult };
147
+ }
148
+
149
+ // --- main ---
150
+ (async () => {
151
+ try {
152
+ if (cmd === 'find') {
153
+ await find(email);
154
+ } else if (cmd === 'purge') {
155
+ await purge(email);
156
+ }
157
+ process.exit(0);
158
+ } catch (e) {
159
+ console.error('✗ Unexpected error:', e);
160
+ process.exit(2);
161
+ }
162
+ })();
@@ -236,7 +236,7 @@ class BaseCommand {
236
236
  this.log(chalk.gray(' (Stripe webhook forwarding is currently disabled - coming soon!)\n'));
237
237
  return null;
238
238
 
239
- // Load .env so STRIPE_SECRET_KEY and BACKEND_MANAGER_KEY are available
239
+ // Load .env so STRIPE_SECRET_KEY and BACKEND_MANAGER_WEBHOOK_KEY are available
240
240
  const envPath = path.join(functionsDir, '.env');
241
241
  if (jetpack.exists(envPath)) {
242
242
  require('dotenv').config({ path: envPath, quiet: true });
@@ -248,9 +248,9 @@ class BaseCommand {
248
248
  return null;
249
249
  }
250
250
 
251
- // Check for Backend Manager key
252
- if (!process.env.BACKEND_MANAGER_KEY) {
253
- this.log(chalk.gray(' (Stripe webhook forwarding disabled - BACKEND_MANAGER_KEY not set in .env)\n'));
251
+ // Check for Backend Manager webhook key
252
+ if (!process.env.BACKEND_MANAGER_WEBHOOK_KEY) {
253
+ this.log(chalk.gray(' (Stripe webhook forwarding disabled - BACKEND_MANAGER_WEBHOOK_KEY not set in .env)\n'));
254
254
  return null;
255
255
  }
256
256
 
@@ -276,7 +276,7 @@ class BaseCommand {
276
276
  }
277
277
  }
278
278
 
279
- const forwardUrl = `http://localhost:${hostingPort}/backend-manager/payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_KEY}`;
279
+ const forwardUrl = `http://localhost:${hostingPort}/backend-manager/payments/webhook?processor=stripe&key=${process.env.BACKEND_MANAGER_WEBHOOK_KEY}`;
280
280
 
281
281
  this.log(chalk.gray(` Stripe webhook forwarding -> localhost:${hostingPort}\n`));
282
282
 
@@ -1,15 +1,20 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { spawn } = require('child_process');
4
5
  const chalk = require('chalk').default;
5
6
  const jetpack = require('fs-jetpack');
6
7
  const JSON5 = require('json5');
7
- 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
11
  const { writeTestMode, captureSyncedEnv } = require('../../test/utils/test-mode-file');
12
12
 
13
+ // Used by both `npx mgr emulator` and `npx mgr test` auto-start path.
14
+ // Note: `emulators:start` enables the UI by default (controlled by firebase.json's
15
+ // `emulators.ui.enabled`), so no `--ui` flag here — that flag only exists on `:exec`.
16
+ const EMULATOR_FLAGS = '--only functions,firestore,auth,database,hosting,pubsub';
17
+
13
18
  class EmulatorCommand extends BaseCommand {
14
19
  async execute() {
15
20
  this.log(chalk.cyan('\n Starting Firebase emulator (keep-alive mode)...\n'));
@@ -45,23 +50,40 @@ class EmulatorCommand extends BaseCommand {
45
50
  // Start Stripe webhook forwarding in background
46
51
  this.startStripeWebhookForwarding();
47
52
 
48
- // Run emulator with keep-alive command (use single quotes since runWithEmulator wraps in double quotes)
49
- const keepAliveCommand = "echo ''; echo 'Emulator ready. Press Ctrl+C to shut down...'; sleep 86400";
50
-
53
+ // Keep-alive: boot emulators and wait for Ctrl+C. No "command" subprocess
54
+ // the emulator child IS the foreground process from the user's perspective.
51
55
  try {
52
- await this.runWithEmulator(keepAliveCommand);
53
- } catch (error) {
54
- // User pressed Ctrl+C - this is expected
56
+ const { shutdown, exitPromise } = await this.startEmulators();
57
+
58
+ this.log(chalk.gray('\n Emulator ready. Press Ctrl+C to shut down...\n'));
59
+
60
+ const onSigint = async () => {
61
+ this.log(chalk.gray('\n Shutting down emulator...'));
62
+ await shutdown();
63
+ this.log(chalk.gray(' Emulator stopped.\n'));
64
+ process.exit(0);
65
+ };
66
+ process.once('SIGINT', onSigint);
67
+
68
+ // Resolve if the emulator dies on its own (crash, port conflict, etc.)
69
+ await exitPromise;
70
+ process.removeListener('SIGINT', onSigint);
55
71
  this.log(chalk.gray('\n Emulator stopped.\n'));
72
+ } catch (error) {
73
+ this.logError(`Emulator error: ${error.message || error}`);
74
+ process.exit(1);
56
75
  }
57
76
  }
58
77
 
59
78
  /**
60
- * Run a command with Firebase emulator
61
- * @param {string} command - The command to execute inside the Firebase emulator
62
- * @returns {Promise<void>}
79
+ * Boot Firebase emulators as a long-running child process.
80
+ * Stdout/stderr are teed to console + emulator.log.
81
+ * Resolves once the emulator hub is listening (i.e., emulators are ready).
82
+ * Caller is responsible for calling shutdown() to send SIGTERM and wait for exit.
83
+ *
84
+ * @returns {Promise<{ child: ChildProcess, shutdown: () => Promise<void>, emulatorPorts: object }>}
63
85
  */
64
- async runWithEmulator(command) {
86
+ async startEmulators() {
65
87
  const projectDir = this.main.firebaseProjectPath;
66
88
 
67
89
  // Load emulator ports from firebase.json
@@ -73,24 +95,13 @@ class EmulatorCommand extends BaseCommand {
73
95
  throw new Error('Port conflicts could not be resolved');
74
96
  }
75
97
 
76
- // Wipe stale firebase-tools debug logs + any leftover BEM logs from older
77
- // versions. Keeps the project tree clean across runs.
98
+ // Wipe stale firebase-tools debug logs + any leftover BEM logs from older versions.
78
99
  this.sweepStaleLogs();
79
100
 
80
- // BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
81
- // hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
82
- // pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
83
- // Use double quotes for command wrapper since the command may contain single quotes (JSON strings)
84
- const envPrefix = process.env.TEST_EXTENDED_MODE
85
- ? 'BEM_TESTING=true TEST_EXTENDED_MODE=true'
86
- : 'BEM_TESTING=true';
87
- const emulatorCommand = `${envPrefix} firebase emulators:exec --only functions,firestore,auth,database,hosting,pubsub --ui "${command}"`;
88
-
89
- // Set up log file in the project directory.
90
- // We use a mutable `currentStream` so the test command can request a fresh log
91
- // by touching emulator.log.reset — the watcher below detects it, closes the
92
- // current stream, reopens with flags: 'w' (truncating cleanly from our process'
93
- // perspective, no sparse-file issue), and deletes the sentinel.
101
+ // Set up log file + reset-sentinel watcher.
102
+ // Mutable `currentStream` so the test command can request a fresh log by touching
103
+ // emulator.log.reset the watcher detects it, closes the current stream, and
104
+ // reopens with flags: 'w' (truncating cleanly from our process' perspective).
94
105
  const logPath = this.getLogsPath('emulator.log');
95
106
  const resetSentinelPath = this.getTempPath('emulator.log.reset');
96
107
  const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
@@ -103,11 +114,9 @@ class EmulatorCommand extends BaseCommand {
103
114
  }
104
115
  }
105
116
 
106
- // Clean up any stale sentinel from a prior crashed emulator run
117
+ // Clean up any stale sentinel from a prior crashed run
107
118
  try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* not present, ok */ }
108
119
 
109
- // Watch for the test command's request to roll the log.
110
- // Poll every 500ms — cheap, no fs.watch quirks across platforms.
111
120
  const resetWatcher = setInterval(() => {
112
121
  if (!fs.existsSync(resetSentinelPath)) {
113
122
  return;
@@ -129,37 +138,175 @@ class EmulatorCommand extends BaseCommand {
129
138
  writeToLog('\n');
130
139
  }
131
140
 
132
- this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
141
+ this.log(chalk.gray(` Logs saving to: ${logPath}`));
142
+
143
+ // BEM_TESTING=true is passed so Functions skip external API calls (emails, SendGrid)
144
+ // hosting is included so localhost:5002 rewrites work (e.g., /backend-manager -> bm_api)
145
+ // pubsub is included so scheduled functions (bm_cronDaily) can be triggered in tests
146
+ const env = {
147
+ ...process.env,
148
+ FORCE_COLOR: '1',
149
+ BEM_TESTING: 'true',
150
+ };
133
151
 
134
- await powertools.execute(emulatorCommand, {
135
- log: false,
152
+ // Spawn `firebase emulators:start` as a background child. Use `sh -c` so the
153
+ // user's shell PATH resolves `firebase` consistently with the interactive shell.
154
+ //
155
+ // `detached: true` puts the child into its own process group. We need this so that
156
+ // shutdown() can kill the entire group (sh → firebase → java emulators) by
157
+ // signalling the negative pgid. Without it, SIGTERM to the shell doesn't propagate
158
+ // to firebase or its java grandchildren, leaving orphan firestore/pubsub processes.
159
+ const child = spawn('sh', ['-c', `firebase emulators:start ${EMULATOR_FLAGS}`], {
136
160
  cwd: projectDir,
137
- config: {
138
- stdio: ['inherit', 'pipe', 'pipe'],
161
+ env,
162
+ stdio: ['ignore', 'pipe', 'pipe'],
163
+ detached: true,
164
+ });
165
+
166
+ // Wire readiness detection into the stdout/stderr handlers.
167
+ //
168
+ // We watch for firebase-tools' explicit "All emulators ready!" line — that's the
169
+ // signal that function discovery + load is complete and the runtime can serve HTTP.
170
+ // Port-listening alone isn't enough: firebase-tools binds the functions socket
171
+ // ~5-10s before user functions are actually loadable, so HTTP requests fail with
172
+ // ECONNREFUSED / "fetch failed" if we proceed when only the port is open.
173
+ let readyResolve;
174
+ let readyReject;
175
+ const readyPromise = new Promise((resolve, reject) => {
176
+ readyResolve = resolve;
177
+ readyReject = reject;
178
+ });
179
+ let ready = false;
180
+ const READY_MARKER = /All emulators ready/i;
181
+
182
+ child.stdout.on('data', (data) => {
183
+ process.stdout.write(data);
184
+ writeToLog(data);
185
+ if (!ready && READY_MARKER.test(data.toString())) {
186
+ ready = true;
187
+ readyResolve();
188
+ }
189
+ });
190
+
191
+ child.stderr.on('data', (data) => {
192
+ process.stderr.write(data);
193
+ writeToLog(data);
194
+ // firebase-tools prints the ready line to stderr sometimes — watch both.
195
+ if (!ready && READY_MARKER.test(data.toString())) {
196
+ ready = true;
197
+ readyResolve();
198
+ }
199
+ });
200
+
201
+ // Track exit state so shutdown() can resolve when the process is gone
202
+ let exitPromiseResolve;
203
+ const exitPromise = new Promise((resolve) => {
204
+ exitPromiseResolve = resolve;
205
+ });
206
+
207
+ child.on('close', (code, signal) => {
208
+ clearInterval(resetWatcher);
209
+ if (currentStream && !currentStream.destroyed) {
210
+ currentStream.end();
211
+ }
212
+ try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
213
+ exitPromiseResolve({ code, signal });
214
+ // If we exited before becoming ready, fail the readiness wait too
215
+ if (!ready) {
216
+ readyReject(new Error(`Emulator child exited before ready (code=${code}, signal=${signal})`));
217
+ }
218
+ });
219
+
220
+ // Race the readiness marker against a 60s timeout
221
+ const readyTimeoutMs = 60000;
222
+ await Promise.race([
223
+ readyPromise,
224
+ new Promise((_, reject) => setTimeout(
225
+ () => reject(new Error(`Emulator did not print "All emulators ready" within ${readyTimeoutMs}ms`)),
226
+ readyTimeoutMs,
227
+ )),
228
+ ]);
229
+
230
+ // shutdown() signals the entire emulator process group (sh + firebase + java
231
+ // grandchildren), waits up to 10s for clean exit, then escalates to SIGKILL.
232
+ //
233
+ // We use `process.kill(-pgid, ...)` instead of `child.kill(...)` because firebase
234
+ // tools spawns several Java subprocesses (firestore + pubsub) that survive if
235
+ // only the sh wrapper is killed. The negative PID targets the whole process group
236
+ // (made possible by `detached: true` above).
237
+ const killGroup = (signal) => {
238
+ try {
239
+ process.kill(-child.pid, signal);
240
+ } catch (e) {
241
+ // ESRCH = group already dead, that's fine
242
+ if (e.code !== 'ESRCH') throw e;
243
+ }
244
+ };
245
+
246
+ const shutdown = async () => {
247
+ if (child.exitCode !== null || child.signalCode !== null) {
248
+ return; // already gone
249
+ }
250
+
251
+ killGroup('SIGTERM');
252
+
253
+ const killTimer = setTimeout(() => {
254
+ if (child.exitCode === null && child.signalCode === null) {
255
+ killGroup('SIGKILL');
256
+ }
257
+ }, 10000);
258
+
259
+ await exitPromise;
260
+ clearTimeout(killTimer);
261
+ };
262
+
263
+ return { child, shutdown, emulatorPorts, exitPromise };
264
+ }
265
+
266
+ /**
267
+ * Boot emulators and run a single command against them. Sends SIGTERM to the emulator
268
+ * when the command exits (or this process is interrupted) and waits for clean shutdown.
269
+ *
270
+ * Used by `npx mgr emulator` for the keep-alive flow (command is a no-op sleep).
271
+ * `npx mgr test`'s auto-start path uses startEmulators() directly so it can tee the
272
+ * test command's output to its own log (test.log) separate from emulator.log.
273
+ *
274
+ * @param {string} command - shell command to run while emulators are up
275
+ */
276
+ async runWithEmulator(command) {
277
+ const { shutdown, exitPromise } = await this.startEmulators();
278
+
279
+ // SIGINT (Ctrl+C) → graceful shutdown
280
+ const onSigint = async () => {
281
+ await shutdown();
282
+ process.exit(0);
283
+ };
284
+ process.once('SIGINT', onSigint);
285
+
286
+ try {
287
+ // Run the user command; when it exits we tear down the emulator.
288
+ const cmdChild = spawn('sh', ['-c', command], {
289
+ cwd: this.main.firebaseProjectPath,
139
290
  env: { ...process.env, FORCE_COLOR: '1' },
140
- },
141
- }, (child) => {
142
- // Tee stdout to both console and log file (strip ANSI codes for clean log)
143
- child.stdout.on('data', (data) => {
144
- process.stdout.write(data);
145
- writeToLog(data);
291
+ stdio: 'inherit',
146
292
  });
147
293
 
148
- // Tee stderr to both console and log file (strip ANSI codes for clean log)
149
- child.stderr.on('data', (data) => {
150
- process.stderr.write(data);
151
- writeToLog(data);
294
+ const cmdExit = await new Promise((resolve) => {
295
+ cmdChild.on('close', (code, signal) => resolve({ code, signal }));
152
296
  });
153
297
 
154
- // Clean up log stream + watcher when child exits
155
- child.on('close', () => {
156
- clearInterval(resetWatcher);
157
- if (currentStream && !currentStream.destroyed) {
158
- currentStream.end();
159
- }
160
- try { fs.unlinkSync(resetSentinelPath); } catch (e) { /* ok */ }
161
- });
162
- });
298
+ process.removeListener('SIGINT', onSigint);
299
+ await shutdown();
300
+ await exitPromise;
301
+
302
+ if (cmdExit.code !== 0) {
303
+ throw Object.assign(new Error(`Command exited with code ${cmdExit.code}`), { code: cmdExit.code });
304
+ }
305
+ } catch (e) {
306
+ process.removeListener('SIGINT', onSigint);
307
+ await shutdown();
308
+ throw e;
309
+ }
163
310
  }
164
311
 
165
312
  /**
@@ -1,6 +1,7 @@
1
1
  const BaseCommand = require('./base-command');
2
2
  const path = require('path');
3
3
  const fs = require('fs');
4
+ const { spawn } = require('child_process');
4
5
  const chalk = require('chalk').default;
5
6
  const jetpack = require('fs-jetpack');
6
7
  const JSON5 = require('json5');
@@ -69,7 +70,7 @@ class TestCommand extends BaseCommand {
69
70
  await this.runTestsDirectly(testCommand, functionsDir, emulatorPorts);
70
71
  } else {
71
72
  this.log(chalk.cyan('Starting emulator and running tests...'));
72
- await this.runEmulatorTests(testCommand);
73
+ await this.runEmulatorTests(testCommand, functionsDir);
73
74
  }
74
75
  }
75
76
 
@@ -123,6 +124,7 @@ class TestCommand extends BaseCommand {
123
124
 
124
125
  // Derive computed values (not in config file)
125
126
  const backendManagerKey = argv.key || process.env.BACKEND_MANAGER_KEY;
127
+ const backendManagerWebhookKey = argv.webhookKey || process.env.BACKEND_MANAGER_WEBHOOK_KEY;
126
128
  const contactEmail = config.brand?.contact?.email || '';
127
129
  const domain = contactEmail.includes('@') ? contactEmail.split('@')[1] : '';
128
130
 
@@ -138,6 +140,12 @@ class TestCommand extends BaseCommand {
138
140
  return null;
139
141
  }
140
142
 
143
+ if (!backendManagerWebhookKey) {
144
+ this.logError('Error: Missing backend manager webhook key');
145
+ this.log(chalk.gray(' Set BACKEND_MANAGER_WEBHOOK_KEY in your .env file or pass --webhook-key flag'));
146
+ return null;
147
+ }
148
+
141
149
  if (!config.brand?.id) {
142
150
  this.logError('Error: Missing brand.id in backend-manager-config.json');
143
151
  return null;
@@ -152,6 +160,7 @@ class TestCommand extends BaseCommand {
152
160
  return {
153
161
  ...config,
154
162
  backendManagerKey,
163
+ backendManagerWebhookKey,
155
164
  domain,
156
165
  };
157
166
  }
@@ -280,23 +289,85 @@ class TestCommand extends BaseCommand {
280
289
  }
281
290
 
282
291
  /**
283
- * Run tests with Firebase emulator (starts emulator, runs tests, shuts down)
292
+ * Run tests with Firebase emulator (starts emulator, runs tests, shuts down).
293
+ *
294
+ * Two real child processes are used so emulator output and test-runner output
295
+ * land in separate log files:
296
+ * - `emulator.log` — `firebase emulators:start` stdout/stderr (managed by EmulatorCommand)
297
+ * - `test.log` — the test-runner subprocess stdout/stderr (managed here)
284
298
  */
285
- async runEmulatorTests(testCommand) {
299
+ async runEmulatorTests(testCommand, functionsDir) {
286
300
  this.log(chalk.gray(' Starting Firebase emulator...\n'));
287
301
 
288
- // Use EmulatorCommand to run tests with emulator
289
302
  const emulatorCmd = new EmulatorCommand(this.main);
303
+ let started;
290
304
 
291
305
  try {
292
- await emulatorCmd.runWithEmulator(testCommand);
306
+ started = await emulatorCmd.startEmulators();
293
307
  } catch (error) {
294
- // Only exit with error if it wasn't a user-initiated exit
295
- if (error.code !== 0) {
296
- this.logError(`Emulator error: ${error.message || error}`);
297
- }
308
+ this.logError(`Emulator error: ${error.message || error}`);
298
309
  process.exit(1);
299
310
  }
311
+
312
+ const { shutdown, exitPromise, emulatorPorts } = started;
313
+
314
+ // Forward Ctrl+C to a clean emulator shutdown
315
+ const onSigint = async () => {
316
+ this.log(chalk.gray('\n Shutting down emulator...'));
317
+ await shutdown();
318
+ process.exit(130);
319
+ };
320
+ process.once('SIGINT', onSigint);
321
+
322
+ // Print the same connection summary the existing-emulator path shows
323
+ this.log('');
324
+ this.log(chalk.gray(` Hosting: http://127.0.0.1:${emulatorPorts.hosting}`));
325
+ this.log(chalk.gray(` Firestore: 127.0.0.1:${emulatorPorts.firestore}`));
326
+ this.log(chalk.gray(` Auth: 127.0.0.1:${emulatorPorts.auth}`));
327
+ this.log(chalk.gray(` UI: http://127.0.0.1:${emulatorPorts.ui}`));
328
+
329
+ // Spawn the test runner as its own child so its stdout/stderr can be teed to test.log
330
+ const logPath = this.getLogsPath('test.log');
331
+ const logStream = fs.createWriteStream(logPath, { flags: 'w' });
332
+ const stripAnsi = (str) => str.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, '');
333
+
334
+ this.log(chalk.gray(` Logs saving to: ${logPath}\n`));
335
+
336
+ let testExitCode = 0;
337
+
338
+ try {
339
+ const testChild = spawn('sh', ['-c', testCommand], {
340
+ cwd: functionsDir,
341
+ env: { ...process.env, FORCE_COLOR: '1' },
342
+ stdio: ['inherit', 'pipe', 'pipe'],
343
+ });
344
+
345
+ testChild.stdout.on('data', (data) => {
346
+ process.stdout.write(data);
347
+ if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
348
+ });
349
+
350
+ testChild.stderr.on('data', (data) => {
351
+ process.stderr.write(data);
352
+ if (!logStream.destroyed) logStream.write(stripAnsi(data.toString()));
353
+ });
354
+
355
+ testExitCode = await new Promise((resolve) => {
356
+ testChild.on('close', (code) => {
357
+ if (!logStream.destroyed) logStream.end();
358
+ resolve(code ?? 1);
359
+ });
360
+ });
361
+ } catch (error) {
362
+ this.logError(`Test runner error: ${error.message || error}`);
363
+ testExitCode = 1;
364
+ } finally {
365
+ process.removeListener('SIGINT', onSigint);
366
+ await shutdown();
367
+ await exitPromise;
368
+ }
369
+
370
+ process.exit(testExitCode);
300
371
  }
301
372
  }
302
373
 
@@ -103,7 +103,7 @@ function buildBrandConfig(config) {
103
103
  */
104
104
  function fetchRemoteBrand(brandUrl) {
105
105
  return fetch(`${brandUrl}/backend-manager/brand`, {
106
- timeout: 30000,
106
+ timeout: 120000,
107
107
  tries: 3,
108
108
  response: 'json',
109
109
  });
@@ -174,7 +174,7 @@ async function harvest(assistant, settings) {
174
174
 
175
175
  function getURLContent(url) {
176
176
  return fetch(url, {
177
- timeout: 30000,
177
+ timeout: 120000,
178
178
  tries: 3,
179
179
  response: 'raw',
180
180
  headers: {