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.
- package/CHANGELOG.md +35 -0
- package/CLAUDE.md +6 -4
- package/bin/backend-manager +10 -1
- package/docs/admin-post-route.md +2 -0
- package/docs/ai-library.md +29 -2
- package/docs/cli-output.md +122 -0
- package/docs/common-mistakes.md +1 -1
- package/docs/environment-detection.md +87 -3
- package/docs/marketing-campaigns.md +1 -1
- package/docs/payment-system.md +1 -1
- package/docs/testing.md +64 -17
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +4 -0
- package/src/cli/commands/install.js +2 -2
- package/src/cli/commands/setup-tests/bem-config.js +22 -9
- package/src/cli/commands/setup-tests/helpers/merge-line-files.js +22 -168
- package/src/cli/commands/setup.js +65 -33
- package/src/cli/commands/test.js +1 -1
- package/src/cli/index.js +72 -39
- package/src/cli/utils/ui.js +270 -0
- package/src/defaults/CLAUDE.md +2 -2
- package/src/defaults/test/_init.js +14 -0
- package/src/manager/functions/_legacy/actions/create-post-handler.js +1 -1
- package/src/manager/functions/core/actions/api/admin/edit-post.js +1 -1
- package/src/manager/functions/core/actions/api/general/send-email.js +1 -1
- package/src/manager/functions/core/actions/api/user/delete.js +1 -1
- package/src/manager/functions/core/actions/api/user/oauth2.js +1 -1
- package/src/manager/helpers/analytics.js +3 -1
- package/src/manager/helpers/assistant.js +43 -38
- package/src/manager/index.js +76 -12
- package/src/manager/libraries/ai/index.js +33 -0
- package/src/manager/libraries/ai/providers/openai.js +105 -8
- package/src/manager/libraries/content/ghostii.js +76 -16
- package/src/manager/libraries/email/data/disposable-domains.json +24 -0
- package/src/manager/libraries/email/generators/lib/image-illustrator.js +154 -0
- package/src/manager/libraries/email/generators/newsletter.js +16 -2
- package/src/manager/libraries/payment/discount-codes.js +1 -0
- package/src/manager/routes/admin/post/put.js +1 -1
- package/src/manager/routes/handler/post/post.js +1 -1
- package/src/manager/routes/payments/intent/processors/test.js +1 -1
- package/src/manager/routes/user/delete.js +1 -1
- package/src/manager/routes/user/signup/post.js +4 -2
- package/src/test/runner.js +142 -20
- package/src/test/test-accounts.js +159 -102
- package/src/utils/merge-line-files.js +16 -5
- package/templates/_.env +1 -0
- package/test/events/payments/journey-payments-cancel-endpoint.js +6 -2
- package/test/events/payments/journey-payments-cancel.js +6 -3
- package/test/events/payments/journey-payments-failure.js +6 -3
- package/test/events/payments/journey-payments-plan-change.js +6 -3
- package/test/events/payments/journey-payments-refund-webhook.js +6 -2
- package/test/events/payments/journey-payments-suspend.js +6 -3
- package/test/events/payments/journey-payments-trial-cancel.js +6 -3
- package/test/events/payments/journey-payments-trial.js +10 -4
- package/test/events/payments/journey-payments-uid-resolution.js +6 -2
- package/test/events/payments/journey-payments-upgrade.js +6 -3
- package/test/helpers/content/ghostii-blocks.js +134 -0
- package/test/helpers/environment.js +230 -0
- package/test/helpers/merge-line-files.js +271 -0
- package/test/routes/marketing/webhook-forward.js +14 -7
- package/test/routes/payments/cancel.js +4 -1
- package/test/routes/payments/dispute-alert.js +9 -6
- package/test/routes/payments/intent.js +55 -14
- package/test/routes/payments/portal.js +4 -1
- 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
|
+
};
|
package/src/defaults/CLAUDE.md
CHANGED
|
@@ -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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
return self.meta.environment === 'development';
|
|
305
|
+
return this.Manager.isDevelopment();
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
BackendAssistant.prototype.isProduction = function () {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return self.meta.environment === 'production';
|
|
309
|
+
return this.Manager.isProduction();
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
BackendAssistant.prototype.isTesting = function () {
|
|
307
|
-
|
|
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.
|
|
341
|
+
} else if (!self.isProduction()) {
|
|
337
342
|
console.log.apply(console, logs);
|
|
338
343
|
} else {
|
|
339
344
|
self.ref.functions.logger.write({
|
package/src/manager/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
335
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
744
|
+
&& self.isDevelopment()
|
|
681
745
|
&& options.clear
|
|
682
746
|
) {
|
|
683
747
|
self.assistant.log('Removed temporary file @', location);
|