backend-manager 5.2.18 → 5.3.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 (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/CLAUDE.md +6 -4
  3. package/bin/backend-manager +10 -1
  4. package/docs/admin-post-route.md +2 -0
  5. package/docs/ai-library.md +29 -2
  6. package/docs/cli-output.md +122 -0
  7. package/docs/common-mistakes.md +1 -1
  8. package/docs/environment-detection.md +87 -3
  9. package/docs/marketing-campaigns.md +1 -1
  10. package/docs/payment-system.md +1 -1
  11. package/docs/testing.md +64 -17
  12. package/package.json +1 -1
  13. package/src/cli/commands/base-command.js +4 -0
  14. package/src/cli/commands/install.js +2 -2
  15. package/src/cli/commands/setup-tests/bem-config.js +22 -9
  16. package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
  17. package/src/cli/commands/setup.js +65 -33
  18. package/src/cli/commands/test.js +1 -1
  19. package/src/cli/index.js +72 -39
  20. package/src/cli/utils/ui.js +270 -0
  21. package/src/defaults/CLAUDE.md +2 -2
  22. package/src/defaults/test/_init.js +14 -0
  23. package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
  24. package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
  25. package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
  26. package/src/manager/functions/core/actions/api/user/delete.js +1 -1
  27. package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
  28. package/src/manager/helpers/analytics.js +3 -1
  29. package/src/manager/helpers/assistant.js +43 -38
  30. package/src/manager/index.js +76 -12
  31. package/src/manager/libraries/ai/index.js +33 -0
  32. package/src/manager/libraries/ai/providers/openai.js +105 -8
  33. package/src/manager/libraries/content/ghostii.js +76 -16
  34. package/src/manager/libraries/email/data/disposable-domains.json +24 -0
  35. package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
  36. package/src/manager/libraries/email/generators/newsletter.js +16 -2
  37. package/src/manager/libraries/payment/discount-codes.js +1 -0
  38. package/src/manager/routes/admin/post/put.js +1 -1
  39. package/src/manager/routes/handler/post/post.js +1 -1
  40. package/src/manager/routes/payments/intent/processors/test.js +1 -1
  41. package/src/manager/routes/user/delete.js +1 -1
  42. package/src/manager/routes/user/signup/post.js +4 -2
  43. package/src/test/runner.js +142 -20
  44. package/src/test/test-accounts.js +159 -102
  45. package/src/utils/merge-line-files.js +16 -5
  46. package/templates/_.env +1 -0
  47. package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
  48. package/test/events/payments/journey-payments-cancel.js +6 -3
  49. package/test/events/payments/journey-payments-failure.js +6 -3
  50. package/test/events/payments/journey-payments-plan-change.js +6 -3
  51. package/test/events/payments/journey-payments-refund-webhook.js +6 -2
  52. package/test/events/payments/journey-payments-suspend.js +6 -3
  53. package/test/events/payments/journey-payments-trial-cancel.js +6 -3
  54. package/test/events/payments/journey-payments-trial.js +10 -4
  55. package/test/events/payments/journey-payments-uid-resolution.js +6 -2
  56. package/test/events/payments/journey-payments-upgrade.js +6 -3
  57. package/test/helpers/content/ghostii-blocks.js +134 -0
  58. package/test/helpers/environment.js +230 -0
  59. package/test/helpers/merge-line-files.js +271 -0
  60. package/test/routes/marketing/webhook-forward.js +14 -7
  61. package/test/routes/payments/cancel.js +4 -1
  62. package/test/routes/payments/dispute-alert.js +9 -6
  63. package/test/routes/payments/intent.js +55 -14
  64. package/test/routes/payments/portal.js +4 -1
  65. package/test/routes/payments/refund.js +4 -1
@@ -0,0 +1,270 @@
1
+ const chalk = require('chalk').default;
2
+
3
+ /**
4
+ * Shared CLI styling helpers — the SSOT for BEM's console output look.
5
+ *
6
+ * Mirrors the OMEGA Manager (omega-manager) styling conventions so every BEM
7
+ * command renders with the same dividers, indentation, timestamps, colors, and
8
+ * status symbols. Pull these helpers into any command (setup/serve/deploy/test/
9
+ * emulator/...) instead of hand-rolling chalk + console.log.
10
+ *
11
+ * Conventions (matching OMEGA):
12
+ * - 70-char `━` horizontal rules wrap section titles.
13
+ * - Indentation is 2 spaces per level (level 1 = 2 spaces, level 2 = 4, ...).
14
+ * - Dimmed labels (`ID:`, `URL:`, `Local:`) with normal-weight values.
15
+ * - Status symbols: → (running) ✓ (pass) ✗ (fail) ⊘ (skip) ⚠ (warn) ✅ (done).
16
+ * - Timestamps via `new Date().toLocaleTimeString()` (e.g. "7:47:12 PM").
17
+ */
18
+
19
+ // Divider width + character (OMEGA uses 70 × `━`).
20
+ const RULE_WIDTH = 70;
21
+ const RULE_CHAR = '━';
22
+
23
+ // Status symbols — the single source of truth for BEM's CLI iconography.
24
+ const SYMBOLS = {
25
+ running: '→',
26
+ pass: '✓',
27
+ fail: '✗',
28
+ skip: '⊘',
29
+ warn: '⚠',
30
+ done: '✅',
31
+ rocket: '🚀',
32
+ add: '+',
33
+ change: '↻',
34
+ };
35
+
36
+ /** Two-space-per-level indentation. */
37
+ function indent(level = 1) {
38
+ return ' '.repeat(level);
39
+ }
40
+
41
+ /** Locale time string, e.g. "7:47:12 PM". */
42
+ function timestamp() {
43
+ return new Date().toLocaleTimeString();
44
+ }
45
+
46
+ /** A full-width horizontal rule in the given chalk color (default cyan). */
47
+ function rule(color = chalk.cyan) {
48
+ return color(RULE_CHAR.repeat(RULE_WIDTH));
49
+ }
50
+
51
+ /** Print a blank line. */
52
+ function blank() {
53
+ console.log('');
54
+ }
55
+
56
+ /**
57
+ * Print the top-level program banner, e.g. `🚀 Backend Manager`.
58
+ * @param {string} title
59
+ */
60
+ function banner(title) {
61
+ blank();
62
+ console.log(chalk.bold.cyan(`${SYMBOLS.rocket} ${title}`));
63
+ blank();
64
+ }
65
+
66
+ /**
67
+ * Print a divider-wrapped section header with an optional subtitle + timestamp.
68
+ *
69
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
70
+ * Title subtitle @ 7:47:12 PM
71
+ * ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
72
+ *
73
+ * @param {string} title - Bold white title.
74
+ * @param {object} [opts]
75
+ * @param {string} [opts.subtitle] - Dimmed/colored text after the title (e.g. a URL).
76
+ * @param {Function} [opts.subtitleColor] - chalk fn for the subtitle (default cyan).
77
+ * @param {boolean} [opts.time=true] - Append `@ <time>`.
78
+ * @param {Function} [opts.color] - chalk fn for the rules (default cyan).
79
+ */
80
+ function header(title, opts = {}) {
81
+ const color = opts.color || chalk.cyan;
82
+ const subtitleColor = opts.subtitleColor || chalk.cyan;
83
+ const time = opts.time !== false;
84
+
85
+ const parts = [chalk.bold.white(title)];
86
+ if (opts.subtitle) {
87
+ parts.push(subtitleColor(opts.subtitle));
88
+ }
89
+ if (time) {
90
+ parts.push(chalk.dim(`@ ${timestamp()}`));
91
+ }
92
+
93
+ console.log(rule(color));
94
+ console.log(`${indent(1)}${parts.join(' ')}`);
95
+ console.log(rule(color));
96
+ }
97
+
98
+ /**
99
+ * Print a magenta section label like `[SETUP]`.
100
+ * @param {string} label
101
+ */
102
+ function section(label) {
103
+ blank();
104
+ console.log(`${indent(1)}${chalk.bold.magenta(`[${String(label).toUpperCase()}]`)}`);
105
+ }
106
+
107
+ /**
108
+ * Print a dimmed `Label: value` line at a given indent level.
109
+ * @param {string} label
110
+ * @param {string} value
111
+ * @param {object} [opts]
112
+ * @param {number} [opts.level=1]
113
+ * @param {number} [opts.pad] - Pad the label (incl. trailing colon) to this width for alignment.
114
+ * @param {Function} [opts.valueColor] - chalk fn for the value (default none).
115
+ */
116
+ function field(label, value, opts = {}) {
117
+ const level = opts.level || 1;
118
+ let labelText = `${label}:`;
119
+ if (opts.pad) {
120
+ labelText = labelText.padEnd(opts.pad);
121
+ }
122
+ const valueText = opts.valueColor ? opts.valueColor(value) : value;
123
+ console.log(`${indent(level)}${chalk.dim(labelText)} ${valueText}`);
124
+ }
125
+
126
+ /**
127
+ * Print a status line: `<symbol> <text>` at a given indent level.
128
+ * @param {('running'|'pass'|'fail'|'skip'|'warn'|'add'|'change')} kind
129
+ * @param {string} text
130
+ * @param {object} [opts]
131
+ * @param {number} [opts.level=1]
132
+ * @param {string} [opts.detail] - Dimmed trailing detail.
133
+ */
134
+ function status(kind, text, opts = {}) {
135
+ const level = opts.level || 1;
136
+ const colorByKind = {
137
+ running: chalk.dim,
138
+ pass: chalk.green,
139
+ fail: chalk.red,
140
+ skip: chalk.dim,
141
+ warn: chalk.yellow,
142
+ add: chalk.green,
143
+ change: chalk.yellow,
144
+ };
145
+ const color = colorByKind[kind] || chalk.white;
146
+ const symbol = SYMBOLS[kind] || '';
147
+ const detail = opts.detail ? ` ${chalk.dim(opts.detail)}` : '';
148
+ console.log(`${indent(level)}${color(symbol)} ${text}${detail}`);
149
+ }
150
+
151
+ /** Print a plain dimmed note line. */
152
+ function note(text, level = 1) {
153
+ console.log(`${indent(level)}${chalk.dim(text)}`);
154
+ }
155
+
156
+ /**
157
+ * Collects setup-check results and prints an OMEGA-style summary block.
158
+ *
159
+ * Used by the setup command's test runner. `start()` stamps a wall-clock so the
160
+ * summary can report duration; `passed()` / `failed()` record outcomes; the
161
+ * failing check (if any) carries optional detail lines to surface in the block.
162
+ */
163
+ class Summary {
164
+ constructor() {
165
+ this.startTime = null;
166
+ this.passes = 0;
167
+ this.fails = [];
168
+ }
169
+
170
+ start() {
171
+ this.startTime = Date.now();
172
+ return this;
173
+ }
174
+
175
+ pass() {
176
+ this.passes++;
177
+ }
178
+
179
+ /**
180
+ * @param {string} name - Check name.
181
+ * @param {string[]} [details] - Pre-formatted detail lines to show under the failure.
182
+ */
183
+ fail(name, details = []) {
184
+ this.fails.push({ name, details });
185
+ }
186
+
187
+ get total() {
188
+ return this.passes + this.fails.length;
189
+ }
190
+
191
+ /**
192
+ * Format milliseconds into "Xs" / "Xm Ys" / "Xh Ym Zs".
193
+ * @param {number} ms
194
+ */
195
+ _formatDuration(ms) {
196
+ const seconds = Math.floor(ms / 1000);
197
+ if (seconds < 60) {
198
+ return `${seconds}s`;
199
+ }
200
+ const minutes = Math.floor(seconds / 60);
201
+ const remSeconds = seconds % 60;
202
+ if (minutes < 60) {
203
+ return `${minutes}m ${remSeconds}s`;
204
+ }
205
+ const hours = Math.floor(minutes / 60);
206
+ const remMinutes = minutes % 60;
207
+ return `${hours}h ${remMinutes}m ${remSeconds}s`;
208
+ }
209
+
210
+ /**
211
+ * Print the summary block. Green ✅ when everything passed, yellow ⚠ otherwise.
212
+ * @param {object} [opts]
213
+ * @param {string} [opts.hint] - A closing call-to-action line (e.g. how to re-run).
214
+ */
215
+ print(opts = {}) {
216
+ const hasErrors = this.fails.length > 0;
217
+ const headerColor = hasErrors ? chalk.yellow : chalk.green;
218
+ const icon = hasErrors ? SYMBOLS.warn : SYMBOLS.done;
219
+ const elapsed = this.startTime ? this._formatDuration(Date.now() - this.startTime) : '0s';
220
+
221
+ blank();
222
+ console.log(headerColor(RULE_CHAR.repeat(RULE_WIDTH)));
223
+ console.log(`${indent(1)}${headerColor.bold(`${icon} Summary`)}`);
224
+ console.log(headerColor(RULE_CHAR.repeat(RULE_WIDTH)));
225
+
226
+ blank();
227
+ field('Checks', String(this.total), { pad: 11 });
228
+ field('Duration', elapsed, { pad: 11 });
229
+ const failText = hasErrors
230
+ ? chalk.red(`${this.fails.length} failed`)
231
+ : chalk.dim('0 failed');
232
+ field('Results', `${chalk.green(`${this.passes} passed`)}${chalk.dim(', ')}${failText}`, { pad: 11 });
233
+
234
+ if (hasErrors) {
235
+ blank();
236
+ for (const { name, details } of this.fails) {
237
+ console.log(`${indent(1)}${chalk.red(SYMBOLS.fail)} ${chalk.bold(name)}`);
238
+ for (const line of details) {
239
+ console.log(`${indent(3)}${line}`);
240
+ }
241
+ }
242
+ }
243
+
244
+ if (opts.hint) {
245
+ blank();
246
+ console.log(`${indent(1)}${chalk.dim(SYMBOLS.running)} ${opts.hint}`);
247
+ }
248
+
249
+ blank();
250
+ console.log(headerColor(RULE_CHAR.repeat(RULE_WIDTH)));
251
+ }
252
+ }
253
+
254
+ module.exports = {
255
+ chalk,
256
+ RULE_WIDTH,
257
+ RULE_CHAR,
258
+ SYMBOLS,
259
+ indent,
260
+ timestamp,
261
+ rule,
262
+ blank,
263
+ banner,
264
+ header,
265
+ section,
266
+ field,
267
+ status,
268
+ note,
269
+ Summary,
270
+ };
@@ -24,12 +24,12 @@ npx mgr logs:read # read Cloud Functions logs (also: logs:tail to stream
24
24
  npx mgr firestore:get # read a doc from Firestore (also: firestore:set / :query / :delete)
25
25
  npx mgr auth:get # read an Auth user (also: auth:list / :delete / :set-claims)
26
26
  npx mgr install dev # use LOCAL backend-manager source (to test framework edits)
27
- npx mgr install prod # restore the published backend-manager from npm
27
+ npx mgr install live # restore the published backend-manager from npm
28
28
  ```
29
29
 
30
30
  All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
31
31
 
32
- > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install prod` to switch back.
32
+ > Editing the BEM framework source while working here? Run `npx mgr install dev` so this project picks up your uncommitted framework changes (it otherwise uses its installed `node_modules/backend-manager`). Run `npx mgr install live` to switch back.
33
33
 
34
34
  ## Where things live
35
35
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Test lifecycle hook for this project. Runs before any test (not a test itself).
3
+ * See backend-manager/docs/testing.md → "test/_init.js".
4
+ */
5
+
6
+ module.exports = ({ config }) => ({
7
+ // Extra test accounts (one per lifecycle this project exercises):
8
+ // { id, uid, email, properties }. email may use the {domain} placeholder.
9
+ accounts: [],
10
+
11
+ // Seed fixtures into the freshly-flushed emulator, after accounts are created.
12
+ async setup({ admin, accounts }) {
13
+ },
14
+ });
@@ -51,7 +51,7 @@ let Module = {
51
51
  assistant.error(response.error)
52
52
  } else {
53
53
  mailchimp = new Mailchimp(self.Manager.config?.mailchimp?.key ?? '');
54
- await fetch(`${self.Manager.project.apiUrl}/backend-manager`, {
54
+ await fetch(`${self.Manager.getApiUrl()}/backend-manager`, {
55
55
  method: 'POST',
56
56
  response: 'json',
57
57
  body: {
@@ -100,7 +100,7 @@ Module.prototype.fetchPost = function (url) {
100
100
  const payload = self.payload;
101
101
 
102
102
  return new Promise(async function(resolve, reject) {
103
- fetch(`${Manager.project.apiUrl}/backend-manager`, {
103
+ fetch(`${Manager.getApiUrl()}/backend-manager`, {
104
104
  method: 'post',
105
105
  response: 'json',
106
106
  timeout: 190000,
@@ -83,7 +83,7 @@ Module.prototype.main = function () {
83
83
  assistant.log('Email payload:', emailPayload);
84
84
 
85
85
  // Send the email via admin:send-email
86
- await fetch(`${Manager.project.apiUrl}/backend-manager`, {
86
+ await fetch(`${Manager.getApiUrl()}/backend-manager`, {
87
87
  method: 'post',
88
88
  response: 'json',
89
89
  log: true,
@@ -29,7 +29,7 @@ Module.prototype.main = function () {
29
29
 
30
30
  // Signout of all sessions
31
31
  assistant.log(`Signout of all sessions...`);
32
- await fetch(`${self.Manager.project.apiUrl}/backend-manager`, {
32
+ await fetch(`${self.Manager.getApiUrl()}/backend-manager`, {
33
33
  method: 'post',
34
34
  timeout: 60000,
35
35
  response: 'json',
@@ -40,7 +40,7 @@ Module.prototype.main = function () {
40
40
  : payload.data.payload.referrer
41
41
 
42
42
  payload.data.payload.serverUrl = typeof payload.data.payload.serverUrl === 'undefined'
43
- ? `${Manager.project.apiUrl}/backend-manager`
43
+ ? `${Manager.getApiUrl()}/backend-manager`
44
44
  : payload.data.payload.serverUrl
45
45
 
46
46
  payload.data.payload.provider = payload.data.payload.provider || '';
@@ -43,7 +43,9 @@ function Analytics(Manager, options) {
43
43
  // Fix options
44
44
  options.dataSource = options.dataSource || 'server';
45
45
  options.uuid = options.uuid || self.request.ip || Manager.SERVER_UUID;
46
- options.isDevelopment = typeof options.isDevelopment === 'undefined' ? self.assistant.isDevelopment() : options.isDevelopment;
46
+ // Skip real analytics in ANY non-production environment (development OR testing) so
47
+ // test/dev runs never pollute GA4. Intentional `!isProduction()` check.
48
+ options.isDevelopment = typeof options.isDevelopment === 'undefined' ? !self.assistant.isProduction() : options.isDevelopment;
47
49
  options.pageview = typeof options.pageview === 'undefined' ? true : options.pageview;
48
50
  options.version = options.version || Manager.package.version;
49
51
  options.userProperties = options.userProperties || {};
@@ -56,11 +56,13 @@ function tryUrl(self) {
56
56
  const host = req.get('host');
57
57
  const forwardedHost = req.get('x-forwarded-host');
58
58
  const path = req.path;
59
- const functionsUrl = Manager.project.functionsUrl;
59
+ const functionsUrl = Manager.getFunctionsUrl();
60
60
 
61
61
  // Like this becuse "req.originalUrl" does NOT have path for all cases (like when calling https://us-central1-{id}.cloudfunctions.net/giftImport)
62
62
  if (projectType === 'firebase') {
63
- if (self.isDevelopment()) {
63
+ // Non-production (development OR testing) reconstructs the URL from the local
64
+ // functions URL; production uses the request host.
65
+ if (!self.isProduction()) {
64
66
  return forwardedHost
65
67
  ? `${protocol}://${forwardedHost}${path}`
66
68
  : `${functionsUrl}/${self.meta.name}`;
@@ -116,6 +118,26 @@ BackendAssistant.prototype.init = function (ref, options) {
116
118
  self.settings = null;
117
119
  self.schema = null;
118
120
 
121
+ // Set ref FIRST so self.Manager is available below — meta.environment forwards to the
122
+ // Manager's canonical getEnvironment() (the Manager is the SSOT), so the Manager ref must
123
+ // be wired before we resolve the environment.
124
+ ref = ref || {};
125
+
126
+ // An assistant has no independent identity — it's a request-scoped face for its Manager,
127
+ // and forwards environment/url resolution to it. A Manager ref is REQUIRED; constructing
128
+ // one without it is a programming error (use Manager.Assistant(), which injects it).
129
+ if (!ref.Manager || typeof ref.Manager.getEnvironment !== 'function') {
130
+ throw new Error('BackendAssistant.init(): a Manager reference is required (ref.Manager). Construct via Manager.Assistant() so it is injected automatically.');
131
+ }
132
+
133
+ self.ref = {};
134
+ self.ref.req = ref.req || {};
135
+ self.ref.res = ref.res || {};
136
+ self.ref.admin = ref.admin || {};
137
+ self.ref.functions = ref.functions || {};
138
+ self.ref.Manager = ref.Manager;
139
+ self.Manager = self.ref.Manager;
140
+
119
141
  // Set meta
120
142
  self.meta = {};
121
143
 
@@ -127,16 +149,6 @@ BackendAssistant.prototype.init = function (ref, options) {
127
149
  self.meta.environment = options.environment || self.getEnvironment();
128
150
  self.meta.type = options.functionType || process.env.FUNCTION_SIGNATURE_TYPE || 'unknown';
129
151
 
130
- // Set ref
131
- self.ref = {};
132
- ref = ref || {};
133
- self.ref.req = ref.req || {};
134
- self.ref.res = ref.res || {};
135
- self.ref.admin = ref.admin || {};
136
- self.ref.functions = ref.functions || {};
137
- self.ref.Manager = ref.Manager || {};
138
- self.Manager = self.ref.Manager;
139
-
140
152
  // Set ID
141
153
  try {
142
154
  const headers = self?.ref?.req.headers || {};
@@ -274,39 +286,31 @@ BackendAssistant.prototype.init = function (ref, options) {
274
286
  return self;
275
287
  };
276
288
 
289
+ // Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (see index.js). The
290
+ // assistant is a request-scoped face for its Manager and FORWARDS these straight through,
291
+ // so request handlers can call `assistant.getEnvironment()` / `assistant.isTesting()` and
292
+ // get exactly the same answer as `Manager.getEnvironment()`. No duplicated env-var logic
293
+ // lives here — there is one implementation, on the Manager. A Manager ref is guaranteed by
294
+ // init() (it throws otherwise), so these never need a fallback.
295
+ //
296
+ // Returns exactly ONE of 'development' | 'testing' | 'production' (mutually exclusive,
297
+ // testing wins). isDevelopment() is NOT true in testing; isProduction() is a real positive
298
+ // check (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
299
+ // or `isDevelopment() || isTesting()` intentionally.
277
300
  BackendAssistant.prototype.getEnvironment = function () {
278
- // return (process.env.FUNCTIONS_EMULATOR === true || process.env.FUNCTIONS_EMULATOR === 'true' || process.env.ENVIRONMENT !== 'production' ? 'development' : 'production')
279
- if (process.env.ENVIRONMENT === 'production') {
280
- return 'production';
281
- } else if (
282
- process.env.ENVIRONMENT === 'development'
283
- || process.env.FUNCTIONS_EMULATOR === true
284
- || process.env.FUNCTIONS_EMULATOR === 'true'
285
- || process.env.TERM_PROGRAM === 'Apple_Terminal'
286
- || process.env.TERM_PROGRAM === 'vscode'
287
- ) {
288
- return 'development';
289
- } else {
290
- return 'production'
291
- }
292
- };
301
+ return this.Manager.getEnvironment();
302
+ }
293
303
 
294
304
  BackendAssistant.prototype.isDevelopment = function () {
295
- const self = this;
296
-
297
- return self.meta.environment === 'development';
305
+ return this.Manager.isDevelopment();
298
306
  }
299
307
 
300
308
  BackendAssistant.prototype.isProduction = function () {
301
- const self = this;
302
-
303
- return self.meta.environment === 'production';
309
+ return this.Manager.isProduction();
304
310
  }
305
311
 
306
312
  BackendAssistant.prototype.isTesting = function () {
307
- const self = this;
308
-
309
- return process.env.BEM_TESTING === 'true';
313
+ return this.Manager.isTesting();
310
314
  }
311
315
 
312
316
  BackendAssistant.prototype.logProd = function () {
@@ -330,10 +334,11 @@ BackendAssistant.prototype._log = function () {
330
334
  if (LOG_LEVELS[level]) {
331
335
  logs.splice(1, 1);
332
336
 
333
- // Determine how to log
337
+ // Determine how to log. Console for any non-production environment (development OR
338
+ // testing); the Firebase Cloud logger only in production.
334
339
  if (level in console) {
335
340
  console[level].apply(console, logs);
336
- } else if (self.isDevelopment()) {
341
+ } else if (!self.isProduction()) {
337
342
  console.log.apply(console, logs);
338
343
  } else {
339
344
  self.ref.functions.logger.write({
@@ -179,6 +179,54 @@ Manager.prototype.init = function (exporter, options) {
179
179
  });
180
180
  }
181
181
 
182
+ // Environment helpers — the Manager is the SINGLE SOURCE OF TRUTH (mirrors EM/UJM/BXM,
183
+ // where the Manager owns these). getEnvironment() is the ONLY place that reads the raw
184
+ // env vars (BEM_TESTING / ENVIRONMENT / FUNCTIONS_EMULATOR / TERM_PROGRAM); the three
185
+ // is*() checks derive from it live on every call. They return exactly ONE of three
186
+ // mutually-exclusive values — testing wins, then production, else development.
187
+ // The assistant exposes the same methods but FORWARDS to these (assistant.isTesting()
188
+ // → Manager.isTesting()), so request handlers can keep calling `assistant.*`.
189
+ // Defined BEFORE the assistant is constructed so the assistant's own init() can call back.
190
+ self.getEnvironment = function() {
191
+ // Testing takes precedence — set by the test runner / emulator (BEM_TESTING=true).
192
+ if (process.env.BEM_TESTING === 'true') {
193
+ return 'testing';
194
+ }
195
+ if (process.env.ENVIRONMENT === 'production') {
196
+ return 'production';
197
+ } else if (
198
+ process.env.ENVIRONMENT === 'development'
199
+ || process.env.FUNCTIONS_EMULATOR === true
200
+ || process.env.FUNCTIONS_EMULATOR === 'true'
201
+ || process.env.TERM_PROGRAM === 'Apple_Terminal'
202
+ || process.env.TERM_PROGRAM === 'vscode'
203
+ ) {
204
+ return 'development';
205
+ } else {
206
+ // Default: production. BEM's deployed RUNTIME can legitimately lack a dev signal — a
207
+ // live Cloud Function has no FUNCTIONS_EMULATOR and often no ENVIRONMENT var, so
208
+ // "no signal" IS the normal production state. Defaulting to development here would make
209
+ // every deployed function skip real side effects (emails/analytics/webhooks).
210
+ // (Contrast UJM/BXM, whose deployed artifacts always carry their signal, so they default
211
+ // to development — a bare context there is just build tooling.)
212
+ return 'production';
213
+ }
214
+ };
215
+
216
+ // The three checks are mutually exclusive — each true for ONLY its own environment.
217
+ // isDevelopment() is NOT true in testing; isProduction() is a real positive check
218
+ // (never `!isDevelopment()`). Gate "anything non-production" with `!isProduction()`
219
+ // or `isDevelopment() || isTesting()` intentionally.
220
+ self.isDevelopment = function() {
221
+ return self.getEnvironment() === 'development';
222
+ };
223
+ self.isProduction = function() {
224
+ return self.getEnvironment() === 'production';
225
+ };
226
+ self.isTesting = function() {
227
+ return self.getEnvironment() === 'testing';
228
+ };
229
+
182
230
  // Init assistant
183
231
  self.assistant = self.Assistant().init({
184
232
  req: null,
@@ -190,21 +238,33 @@ Manager.prototype.init = function (exporter, options) {
190
238
 
191
239
  // Helper functions for URLs based on environment
192
240
  self.getFunctionsUrl = function(env) {
193
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
241
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
242
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
243
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
244
+ // the parent is a real remote server with no localhost equivalent.
245
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
194
246
  return isDev
195
247
  ? `http://localhost:5001/${self.project.projectId}/${self.project.resourceZone}`
196
248
  : `https://${self.project.resourceZone}-${self.project.projectId}.cloudfunctions.net`;
197
249
  };
198
250
 
199
251
  self.getApiUrl = function(env) {
200
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
252
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
253
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
254
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
255
+ // the parent is a real remote server with no localhost equivalent.
256
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
201
257
  return isDev
202
258
  ? 'http://localhost:5002'
203
259
  : `https://api.${(self.config.brand?.url || '').replace(/^https?:\/\//, '')}`;
204
260
  };
205
261
 
206
262
  self.getWebsiteUrl = function(env) {
207
- const isDev = env === 'development' || (!env && self.assistant.isDevelopment());
263
+ // Auto-detect (no explicit env): development OR testing local URLs. Test runs
264
+ // hit the local emulator, so getApiUrl/getFunctionsUrl/getWebsiteUrl must resolve
265
+ // to localhost. NOTE: getParentApiUrl/getParentUrl are intentionally NOT changed —
266
+ // the parent is a real remote server with no localhost equivalent.
267
+ const isDev = env === 'development' || (!env && (self.isDevelopment() || self.isTesting()));
208
268
  return isDev
209
269
  ? 'https://localhost:4000'
210
270
  : self.config.brand?.url || '';
@@ -254,7 +314,7 @@ Manager.prototype.init = function (exporter, options) {
254
314
  self.project.websiteUrl = self.getWebsiteUrl();
255
315
 
256
316
  // Set environment
257
- process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.assistant.meta.environment;
317
+ process.env.ENVIRONMENT = process.env.ENVIRONMENT || self.getEnvironment();
258
318
 
259
319
  // Set BEM env variables
260
320
  process.env.BEM_FUNCTIONS_URL = self.project.functionsUrl;
@@ -262,9 +322,10 @@ Manager.prototype.init = function (exporter, options) {
262
322
  process.env.BEM_WEBSITE_URL = self.project.websiteUrl;
263
323
 
264
324
  // Use the working Firebase logger that they disabled for whatever reason
325
+ // Load the Firebase logger compat shim only in production (NOT development or testing).
265
326
  if (
266
327
  process.env.GCLOUD_PROJECT
267
- && self.assistant.meta.environment !== 'development'
328
+ && self.isProduction()
268
329
  && options.useFirebaseLogger
269
330
  ) {
270
331
  // require('firebase-functions/lib/logger/compat'); // Old way
@@ -272,7 +333,7 @@ Manager.prototype.init = function (exporter, options) {
272
333
  }
273
334
 
274
335
  // Handle test environment
275
- if (self.assistant.isTesting()) {
336
+ if (self.isTesting()) {
276
337
  self.assistant.log('⚠️⚠️⚠️ Running in TEST environment, some features may be disabled ⚠️⚠️⚠️');
277
338
 
278
339
  // Install the test-mode-file watcher exactly once. Lets the test command
@@ -283,7 +344,7 @@ Manager.prototype.init = function (exporter, options) {
283
344
  }
284
345
 
285
346
  // Handle dev environments
286
- if (self.assistant.isDevelopment()) {
347
+ if (self.isDevelopment()) {
287
348
  const version = require('wonderful-version');
288
349
  const nodeUsing = version.major(process.versions.node);
289
350
  const nodeRequired = version.major(self.package?.engines?.node || '0.0.0');
@@ -331,14 +392,17 @@ Manager.prototype.init = function (exporter, options) {
331
392
  dsn: sentryDSN,
332
393
  release: sentryRelease,
333
394
  beforeSend(event, hint) {
334
- if (self.assistant.isDevelopment() && !self.options.reportErrorsInDev) {
335
- self.assistant.error(new Error('[Sentry] Skipping Sentry because we\'re in development'), hint)
395
+ // Skip Sentry in any non-production environment (development OR testing) unless
396
+ // explicitly opted in. Intentional positive check we never want test-run errors
397
+ // polluting production Sentry.
398
+ if (!self.isProduction() && !self.options.reportErrorsInDev) {
399
+ self.assistant.error(new Error('[Sentry] Skipping Sentry because we\'re not in production'), hint)
336
400
  return null;
337
401
  }
338
402
  event.tags = event.tags || {};
339
403
  event.tags['function.name'] = self.assistant.meta.name;
340
404
  event.tags['function.type'] = self.assistant.meta.type;
341
- event.tags['environment'] = self.assistant.meta.environment;
405
+ event.tags['environment'] = self.getEnvironment();
342
406
  return event;
343
407
  },
344
408
  });
@@ -381,7 +445,7 @@ Manager.prototype.init = function (exporter, options) {
381
445
  }
382
446
 
383
447
  // Fetch stats
384
- if (self.assistant.isDevelopment() && options.fetchStats) {
448
+ if (self.isDevelopment() && options.fetchStats) {
385
449
  setTimeout(function () {
386
450
  self.assistant.log('Fetching meta/stats...');
387
451
  self.libraries.admin
@@ -677,7 +741,7 @@ Manager.prototype.storage = function (options) {
677
741
  // Clear temporary storage
678
742
  if (
679
743
  options.temporary
680
- && self.assistant.isDevelopment()
744
+ && self.isDevelopment()
681
745
  && options.clear
682
746
  ) {
683
747
  self.assistant.log('Removed temporary file @', location);