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
@@ -38,31 +38,44 @@ class BemConfigTest extends BaseTest {
38
38
  }
39
39
 
40
40
  async fix() {
41
- console.log(chalk.red(`There is no automatic fix for this check.`));
42
- console.log(chalk.red(`You need to open backend-manager-config.json and set each of these keys:`));
41
+ const ui = require('../../utils/ui');
43
42
 
44
43
  // Write if it doesn't exist
45
44
  if (!this.context.hasContent(this.self.bemConfigJSON)) {
46
45
  jetpack.write(`${this.self.firebaseProjectPath}/functions/backend-manager-config.json`, {});
47
46
  }
48
47
 
49
- // Log what keys are missing
48
+ // Collect the keys that are still missing (these are what the user must fill in).
49
+ const missing = [];
50
50
  powertools.getKeys(bemConfigTemplate).forEach((key) => {
51
51
  // Skip if an ancestor is explicitly set to a non-object value (e.g. stripe: false)
52
52
  if (this._isAncestorDisabled(key)) {
53
53
  return;
54
54
  }
55
-
56
55
  const userValue = _.get(this.self.bemConfigJSON, key, undefined);
57
-
58
56
  if (typeof userValue === 'undefined') {
59
- console.log(chalk.red.bold(`${key}`));
60
- } else {
61
- console.log(chalk.red(`${key} (${userValue})`));
57
+ missing.push(key);
62
58
  }
63
59
  });
64
60
 
65
- throw new Error('Missing required backend-manager-config.json keys');
61
+ ui.note(`Open ${chalk.bold('backend-manager-config.json')} and set the missing keys below:`, 3);
62
+ for (const key of missing) {
63
+ console.log(`${ui.indent(4)}${chalk.red('•')} ${key}`);
64
+ }
65
+
66
+ // Surface a compact version in the summary block (the full list printed above).
67
+ const preview = missing.slice(0, 8);
68
+ const summaryDetails = [
69
+ chalk.dim(`Set ${chalk.bold(missing.length)} missing key(s) in backend-manager-config.json:`),
70
+ ...preview.map((key) => `${chalk.red('•')} ${key}`),
71
+ ];
72
+ if (missing.length > preview.length) {
73
+ summaryDetails.push(chalk.dim(`…and ${missing.length - preview.length} more (see list above)`));
74
+ }
75
+
76
+ const error = new Error('Missing required backend-manager-config.json keys');
77
+ error.summaryDetails = summaryDetails;
78
+ throw error;
66
79
  }
67
80
  /**
68
81
  * Check if any ancestor of a dot-notation key is a non-object value (e.g. false)
@@ -1,181 +1,35 @@
1
1
  /**
2
- * Smart merge for line-based files (.gitignore, .env)
3
- * Preserves custom values while updating defaults
2
+ * SSOT shim for line-based file merging (.env / .gitignore / CLAUDE.md).
3
+ *
4
+ * The real merge logic lives in `src/utils/merge-line-files.js` — a key-based
5
+ * merge that keeps each KEY under its template header and promotes/migrates keys
6
+ * between the Default/Custom sections correctly. This file used to contain a
7
+ * SECOND, positional implementation that zipped comment lines and value lines by
8
+ * index; an off-by-one there is what historically scrambled consumers' `.env`
9
+ * files. That duplicate is gone — this module now just re-exports the canonical
10
+ * impl and adds the marker-name aliases + `hasSectionMarkers()` the setup tests
11
+ * (`env-file.js`, `gitignore.js`) consume.
4
12
  */
5
13
 
6
- const DEFAULT_SECTION_MARKER = '# ========== Default Values ==========';
7
- const CUSTOM_SECTION_MARKER = '# ========== Custom Values ==========';
8
-
9
- /**
10
- * Merge line-based files with smart Default/Custom section handling
11
- * @param {string} existingContent - Current file content
12
- * @param {string} newContent - New template content
13
- * @param {string} fileName - File name (.env or .gitignore)
14
- * @returns {string} Merged content
15
- */
16
- function mergeLineBasedFiles(existingContent, newContent, fileName) {
17
- const existingLines = existingContent.split('\n');
18
- const newLines = newContent.split('\n');
19
-
20
- const isEnvFile = fileName === '.env';
21
-
22
- // Parse existing file into default section and custom section
23
- let defaultSection = [];
24
- let customSection = [];
25
- let inCustomSection = false;
26
- let inDefaultSection = false;
27
-
28
- const existingDefaultKeys = new Set();
29
- const existingCustomKeys = new Set();
30
-
31
- for (const line of existingLines) {
32
- const trimmed = line.trim();
33
-
34
- if (trimmed === DEFAULT_SECTION_MARKER) {
35
- inDefaultSection = true;
36
- inCustomSection = false;
37
- continue;
38
- }
39
- if (trimmed === CUSTOM_SECTION_MARKER) {
40
- inCustomSection = true;
41
- inDefaultSection = false;
42
- continue;
43
- }
44
-
45
- if (inCustomSection) {
46
- customSection.push(line);
47
- if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
48
- const key = trimmed.split('=')[0].trim();
49
- if (key) {
50
- existingCustomKeys.add(key);
51
- }
52
- }
53
- } else if (inDefaultSection) {
54
- defaultSection.push(line);
55
- if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
56
- const key = trimmed.split('=')[0].trim();
57
- if (key) {
58
- existingDefaultKeys.add(key);
59
- }
60
- }
61
- }
62
- }
63
-
64
- // Parse new content to build complete default section
65
- const newDefaultSection = [];
66
- const newDefaultKeys = new Set();
67
-
68
- let inNewDefaultSection = false;
69
- let inNewCustomSection = false;
70
-
71
- for (const line of newLines) {
72
- const trimmed = line.trim();
73
-
74
- if (trimmed === DEFAULT_SECTION_MARKER) {
75
- inNewDefaultSection = true;
76
- inNewCustomSection = false;
77
- continue;
78
- }
79
- if (trimmed === CUSTOM_SECTION_MARKER) {
80
- inNewCustomSection = true;
81
- inNewDefaultSection = false;
82
- continue;
83
- }
84
-
85
- if (inNewDefaultSection) {
86
- if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
87
- const key = trimmed.split('=')[0].trim();
88
- if (key) {
89
- newDefaultKeys.add(key);
90
- if (!existingDefaultKeys.has(key) && !existingCustomKeys.has(key)) {
91
- newDefaultSection.push(line);
92
- } else {
93
- newDefaultSection.push(null); // Placeholder
94
- }
95
- } else {
96
- newDefaultSection.push(line);
97
- }
98
- } else {
99
- newDefaultSection.push(line);
100
- }
101
- }
102
- }
103
-
104
- // Merge user's existing default values in the correct order
105
- const mergedDefaultSection = [];
106
- let defaultSectionIndex = 0;
107
-
108
- for (const line of newDefaultSection) {
109
- if (line === null) {
110
- while (defaultSectionIndex < defaultSection.length) {
111
- const userLine = defaultSection[defaultSectionIndex++];
112
- const trimmed = userLine.trim();
113
- if (trimmed && !trimmed.startsWith('#')) {
114
- mergedDefaultSection.push(userLine);
115
- break;
116
- }
117
- }
118
- } else {
119
- mergedDefaultSection.push(line);
120
- }
121
- }
122
-
123
- // Find user-added lines in default section that aren't in new defaults
124
- const userAddedToDefaults = [];
125
-
126
- for (const line of defaultSection) {
127
- const trimmed = line.trim();
128
-
129
- if (!trimmed || trimmed.startsWith('#')) {
130
- continue;
131
- }
132
-
133
- if (isEnvFile) {
134
- const key = trimmed.split('=')[0].trim();
135
- if (key && !newDefaultKeys.has(key) && !existingCustomKeys.has(key)) {
136
- userAddedToDefaults.push(line);
137
- }
138
- } else {
139
- const lineExistsInNewDefaults = newLines.some(newLine => {
140
- return newLine.trim() === trimmed;
141
- });
142
-
143
- if (!lineExistsInNewDefaults) {
144
- userAddedToDefaults.push(line);
145
- }
146
- }
147
- }
148
-
149
- // Build final result
150
- const result = [];
151
-
152
- result.push(DEFAULT_SECTION_MARKER);
153
- result.push(...mergedDefaultSection);
154
-
155
- result.push('');
156
- result.push(CUSTOM_SECTION_MARKER);
157
-
158
- if (userAddedToDefaults.length > 0) {
159
- result.push(...userAddedToDefaults);
160
- }
161
-
162
- result.push(...customSection);
163
-
164
- return result.join('\n');
165
- }
14
+ const {
15
+ mergeLineBasedFiles,
16
+ DEFAULT_MARKER,
17
+ CUSTOM_MARKER,
18
+ } = require('../../../../utils/merge-line-files.js');
166
19
 
167
20
  /**
168
- * Check if file has proper section markers
169
- * @param {string} content - File content
21
+ * Check if a file already has the Default/Custom section markers.
22
+ * @param {string} content
170
23
  * @returns {boolean}
171
24
  */
172
25
  function hasSectionMarkers(content) {
173
- return content.includes(DEFAULT_SECTION_MARKER) && content.includes(CUSTOM_SECTION_MARKER);
26
+ return content.includes(DEFAULT_MARKER) && content.includes(CUSTOM_MARKER);
174
27
  }
175
28
 
176
29
  module.exports = {
177
30
  mergeLineBasedFiles,
178
31
  hasSectionMarkers,
179
- DEFAULT_SECTION_MARKER,
180
- CUSTOM_SECTION_MARKER,
181
- };
32
+ // Names the setup tests import (aliases of the canonical markers).
33
+ DEFAULT_SECTION_MARKER: DEFAULT_MARKER,
34
+ CUSTOM_SECTION_MARKER: CUSTOM_MARKER,
35
+ };
@@ -11,6 +11,7 @@ const bem_allRulesRegex = /(\/\/\/---backend-manager---\/\/\/)(.*?)(\/\/\/------
11
11
  class SetupCommand extends BaseCommand {
12
12
  async execute() {
13
13
  const self = this.main;
14
+ const ui = this.ui;
14
15
 
15
16
  // Load config
16
17
  await this.loadConfig();
@@ -24,7 +25,7 @@ class SetupCommand extends BaseCommand {
24
25
  attempt++;
25
26
 
26
27
  if (maxAttempts > 1) {
27
- this.logSuccess(`\n==== SETUP ATTEMPT ${attempt}/${maxAttempts} ====`);
28
+ ui.section(`Attempt ${attempt}/${maxAttempts}`);
28
29
  }
29
30
 
30
31
  // Reset counters so each attempt starts fresh
@@ -35,16 +36,13 @@ class SetupCommand extends BaseCommand {
35
36
 
36
37
  const allPassed = self.testCount === self.testTotal;
37
38
  if (allPassed) {
38
- if (maxAttempts > 1 && attempt > 1) {
39
- this.logSuccess(`\nAll checks passed on attempt ${attempt}/${maxAttempts}.`);
40
- }
41
39
  return;
42
40
  }
43
41
 
44
42
  if (attempt < maxAttempts) {
45
- this.logWarning(`\nAttempt ${attempt}/${maxAttempts} had failures. Retrying...`);
43
+ ui.status('warn', `Attempt ${attempt}/${maxAttempts} had failures — retrying…`);
46
44
  } else if (maxAttempts > 1) {
47
- this.logWarning(`\nReached retry limit (${maxAttempts}). Some checks still failing.`);
45
+ ui.status('warn', `Reached retry limit (${maxAttempts}) some checks still failing`);
48
46
  }
49
47
  }
50
48
  }
@@ -61,9 +59,15 @@ class SetupCommand extends BaseCommand {
61
59
 
62
60
  async runSetup() {
63
61
  const self = this.main;
62
+ const ui = this.ui;
64
63
  let cwd = jetpack.cwd();
65
64
 
66
- this.logSuccess(`\n---- RUNNING SETUP v${self.default.version} ----`);
65
+ // OMEGA-style banner. Replaces the old `---- RUNNING SETUP ---- ` line.
66
+ ui.banner(`Backend Manager ${chalk.dim(`v${self.default.version}`)}`);
67
+
68
+ // Fresh summary collector for this run (the test runner records into it and
69
+ // prints it on a hard failure; we print it here on success).
70
+ self.setupSummary = new ui.Summary().start();
67
71
 
68
72
  // Load files
69
73
  self.package = loadJSON(`${self.firebaseProjectPath}/functions/package.json`);
@@ -76,14 +80,16 @@ class SetupCommand extends BaseCommand {
76
80
 
77
81
  // Check if package exists
78
82
  if (!hasContent(self.package)) {
79
- this.logError(`Missing functions/package.json :(`);
80
- return;
83
+ ui.status('fail', `Missing ${chalk.bold('functions/package.json')}`);
84
+ ui.note(`Run ${chalk.bold('npx mgr setup')} from inside the ${chalk.bold('functions')} folder of a Firebase project.`);
85
+ process.exit(1);
81
86
  }
82
87
 
83
88
  // Check if we're running from the functions folder
84
89
  if (!cwd.endsWith('functions') && !cwd.endsWith('functions/')) {
85
- this.logError(`Please run ${chalk.bold('npx bm setup')} from the ${chalk.bold('functions')} folder. Run ${chalk.bold('cd functions')}.`);
86
- return;
90
+ ui.status('fail', `Wrong directory`);
91
+ ui.note(`Run ${chalk.bold('npx mgr setup')} from the ${chalk.bold('functions')} folder. Try ${chalk.bold('cd functions')} first.`);
92
+ process.exit(1);
87
93
  }
88
94
 
89
95
  // Load the rules files
@@ -96,9 +102,12 @@ class SetupCommand extends BaseCommand {
96
102
  self.projectUrl = `https://console.firebase.google.com/project/${self.projectId}`;
97
103
  self.apiUrl = `https://api.${(self.bemConfigJSON.brand?.url || '').replace(/^https?:\/\//, '')}`;
98
104
 
99
- // Log
100
- this.log(`ID: `, chalk.bold(`${self.projectId}`));
101
- this.log(`URL:`, chalk.bold(`${self.projectUrl}`));
105
+ // Divider-wrapped header with the project name + Firebase console link.
106
+ const brandName = self.bemConfigJSON.brand?.name || self.projectId;
107
+ ui.header(brandName, { subtitle: self.projectUrl });
108
+ ui.blank();
109
+ ui.field('Project', self.projectId, { pad: 9 });
110
+ ui.field('API', self.apiUrl, { pad: 9, valueColor: chalk.cyan });
102
111
 
103
112
  if (!self.package || !self.package.engines || !self.package.engines.node) {
104
113
  throw new Error('Missing <engines.node> in package.json');
@@ -109,26 +118,26 @@ class SetupCommand extends BaseCommand {
109
118
 
110
119
  // Copy / merge defaults into consumer project root (matches EM/BXM/UJM pattern).
111
120
  // Runs BEFORE tests so any test that inspects scaffolded files sees the merged state.
121
+ ui.section('Defaults');
112
122
  this.copyDefaults();
113
123
 
114
124
  // Run all tests
125
+ ui.section('Checks');
115
126
  await this.runTests();
116
127
 
117
- // Log if using local backend-manager
128
+ // Warn if using local backend-manager
118
129
  if (self.package.dependencies['backend-manager'].includes('file:')) {
119
- this.log('\n' + chalk.yellow(chalk.bold('Warning: ') + 'You are using the local ' + chalk.bold('backend-manager')));
120
- } else {
121
- this.log('\n');
130
+ ui.section('Notices');
131
+ ui.status('warn', `Using the local ${chalk.bold('backend-manager')} source (file: dependency)`, { level: 2 });
122
132
  }
123
133
 
124
134
  // Fetch stats
135
+ ui.section('Stats');
125
136
  await this.fetchStats();
126
137
 
127
- // Log results
128
- this.logSuccess(`Checks finished. Passed ${self.testCount}/${self.testTotal} tests.`);
129
- if (self.testCount !== self.testTotal) {
130
- this.logWarning(`You should continue to run ${chalk.bold('npx bm setup')} until you pass all tests and fix all errors.`);
131
- }
138
+ // Everything passed (a hard failure would have exited via haltSetup). Print
139
+ // the OMEGA-style summary block.
140
+ self.setupSummary.print();
132
141
 
133
142
  // Notify parent if exists
134
143
  if (process.send) {
@@ -162,10 +171,12 @@ class SetupCommand extends BaseCommand {
162
171
  // # ========== Custom Values ========== (consumer-owned)
163
172
  copyDefaults() {
164
173
  const self = this.main;
174
+ const ui = this.ui;
165
175
  const defaultsDir = path.resolve(`${__dirname}/../../defaults`);
166
176
 
167
177
  if (!jetpack.exists(defaultsDir)) {
168
178
  // Defaults dir is optional — older BEM versions didn't have one. If missing, skip silently.
179
+ ui.note('No defaults to scaffold', 2);
169
180
  return;
170
181
  }
171
182
 
@@ -175,16 +186,23 @@ class SetupCommand extends BaseCommand {
175
186
  // matches the EM/BXM/UJM contract if we ever add them.
176
187
  const MERGEABLE_BASENAMES = new Set(['.env', '.gitignore', 'CLAUDE.md']);
177
188
 
189
+ // Track whether we emitted any line so we can show an "up to date" note when nothing changed.
190
+ let touched = 0;
191
+
178
192
  const files = jetpack.find(defaultsDir, { matching: '**/*', recursive: true, files: true, directories: false });
179
193
 
180
194
  for (const src of files) {
181
195
  const rel = path.relative(defaultsDir, src);
182
196
  const segments = rel.split(path.sep);
183
197
 
184
- // Skip "archive" directoriesanything under a path segment starting with `_` and
185
- // followed by a non-`.` character. Matches EM/BXM/UJM convention. The `_.env` /
186
- // `_.gitignore` files are NOT skipped; their leading `_` strips on copy below.
187
- if (segments.some((s) => s.startsWith('_') && !s.startsWith('_.'))) {
198
+ // Skip "archive" DIRECTORIESany non-final path segment starting with `_` and
199
+ // followed by a non-`.` character (e.g. `_legacy/`). Matches EM/BXM/UJM convention.
200
+ // The check is restricted to directory segments (all but the last) so a `_`-prefixed
201
+ // FILENAME still ships e.g. `test/_init.js` copies verbatim (the test runner skips
202
+ // it from discovery on its own). The `_.env` / `_.gitignore` files are likewise not
203
+ // skipped; their leading `_` strips on copy below.
204
+ const dirSegments = segments.slice(0, -1);
205
+ if (dirSegments.some((s) => s.startsWith('_') && !s.startsWith('_.'))) {
188
206
  continue;
189
207
  }
190
208
 
@@ -201,10 +219,12 @@ class SetupCommand extends BaseCommand {
201
219
  const merged = mergeLineBasedFiles(existing, incoming, basename);
202
220
  if (merged !== existing) {
203
221
  jetpack.write(dest, merged);
204
- this.logSuccess(`Merged default → ${target}`);
222
+ ui.status('change', `Merged ${chalk.cyan(target)}`, { level: 2 });
223
+ touched++;
205
224
  }
206
225
  } catch (e) {
207
- this.logWarning(`Failed to merge ${target}: ${e.message}`);
226
+ ui.status('warn', `Failed to merge ${chalk.cyan(target)}`, { detail: e.message, level: 2 });
227
+ touched++;
208
228
  }
209
229
  continue;
210
230
  }
@@ -215,7 +235,12 @@ class SetupCommand extends BaseCommand {
215
235
 
216
236
  // First time: copy as-is.
217
237
  jetpack.copy(src, dest);
218
- this.logSuccess(`Copied default → ${target}`);
238
+ ui.status('add', `Copied ${chalk.cyan(target)}`, { level: 2 });
239
+ touched++;
240
+ }
241
+
242
+ if (touched === 0) {
243
+ ui.note('All defaults up to date', 2);
219
244
  }
220
245
  }
221
246
 
@@ -253,6 +278,10 @@ class SetupCommand extends BaseCommand {
253
278
  // Get all tests
254
279
  const tests = testRegistry.getTests(testContext);
255
280
 
281
+ // Expose the total count so the per-check `[N]` prefix can right-align its
282
+ // width (single- vs double-digit indices stay aligned).
283
+ self.testTotalExpected = tests.length;
284
+
256
285
  // Run each test
257
286
  for (const test of tests) {
258
287
  await self.test(
@@ -281,12 +310,15 @@ class SetupCommand extends BaseCommand {
281
310
  .then(json => json)
282
311
  .catch(e => e);
283
312
 
313
+ const ui = this.ui;
284
314
  if (statsFetchResult instanceof Error) {
285
- if (!statsFetchResult.message.includes('network timeout')) {
286
- this.logWarning(`Ran into error while fetching stats endpoint (${url})`, statsFetchResult);
315
+ if (statsFetchResult.message.includes('network timeout')) {
316
+ ui.status('skip', 'Skipped stats fetch', { detail: 'network timeout', level: 2 });
317
+ } else {
318
+ ui.status('warn', 'Could not fetch stats endpoint', { detail: statsFetchResult.message, level: 2 });
287
319
  }
288
320
  } else {
289
- this.logSuccess(`Stats fetched/created properly.`);
321
+ ui.status('pass', 'Stats fetched/created', { level: 2 });
290
322
  }
291
323
  }
292
324
  }
@@ -36,7 +36,7 @@ class TestCommand extends BaseCommand {
36
36
  const envSubset = captureSyncedEnv(process.env);
37
37
  writeTestMode(projectDir, envSubset);
38
38
  const extended = !!process.env.TEST_EXTENDED_MODE;
39
- this.log(chalk.gray(` Test mode: ${extended ? 'EXTENDED (real APIs)' : 'normal (mocked)'}`));
39
+ this.log(chalk.gray(` Test mode: ${extended ? 'extended (real external APIs)' : 'normal (external APIs skipped)'}`));
40
40
  }
41
41
 
42
42
  // Load emulator ports from firebase.json
package/src/cli/index.js CHANGED
@@ -164,47 +164,80 @@ Main.prototype.process = async function (args) {
164
164
  // Test method for setup command
165
165
  Main.prototype.test = async function(name, fn, fix, args) {
166
166
  const self = this;
167
- let status;
168
167
  const chalk = require('chalk').default;
169
-
170
- return new Promise(async function(resolve, reject) {
171
- let passed = await fn();
172
-
173
- if (passed instanceof Error) {
174
- console.log(chalk.red(passed));
175
- process.exit(0);
176
- } else if (passed) {
177
- status = chalk.green('passed');
178
- self.testCount++;
179
- self.testTotal++;
180
- } else {
181
- status = chalk.red('failed');
182
- self.testTotal++;
183
- }
184
- console.log(chalk.bold(`[${self.testTotal}]`), `${name}:`, status);
185
- if (!passed) {
186
- console.log(chalk.yellow(`Fixing...`));
187
- fix(self, args)
188
- .then((r) => {
189
- console.log(chalk.green(`...done~!`));
190
- resolve();
191
- })
192
- .catch((e) => {
193
- console.log(chalk.red(`Failed to fix: ${e}`));
194
- if (self.options['--continue']) {
195
- console.log(chalk.yellow('⚠️ Continuing despite error because of --continue flag\n'));
196
- setTimeout(function () {
197
- resolve();
198
- }, 5000);
199
- } else {
200
- console.log(chalk.yellow('To force the setup to continue, run with the --continue flag\n'));
201
- reject();
202
- }
203
- });
204
- } else {
205
- resolve();
168
+ const ui = require('./utils/ui');
169
+
170
+ // Prints ` [N] <symbol> <name>` the OMEGA-style per-check status line
171
+ // (indented one level under the `[CHECKS]` section label). The `[N]` bracket
172
+ // is padded to the width of the total check count so single- and double-digit
173
+ // numbers (`[1] ` … `[13]`) keep the symbols/names aligned.
174
+ const indexWidth = String(self.testTotalExpected || 99).length;
175
+ const printLine = (index, kind, label) => {
176
+ const colorByKind = { pass: chalk.green, fail: chalk.red, warn: chalk.yellow };
177
+ const color = colorByKind[kind] || chalk.white;
178
+ const suffix = label ? ` ${chalk.dim(label)}` : '';
179
+ const bracket = `[${index}]`.padEnd(indexWidth + 2, ' ');
180
+ console.log(`${ui.indent(2)}${chalk.dim(bracket)} ${color(ui.SYMBOLS[kind])} ${name}${suffix}`);
181
+ };
182
+
183
+ let passed = await fn();
184
+
185
+ // A check that returns an Error is a hard, unrecoverable failure (e.g. wrong
186
+ // Node version). Print it cleanly and stop — no auto-fix is possible.
187
+ if (passed instanceof Error) {
188
+ self.testTotal++;
189
+ printLine(self.testTotal, 'fail', '');
190
+ const message = passed.message || String(passed);
191
+ ui.status('fail', chalk.red(message), { level: 3 });
192
+ if (self.setupSummary) { self.setupSummary.fail(name, [chalk.red(message)]); }
193
+ self.haltSetup();
194
+ return;
195
+ }
196
+
197
+ if (passed) {
198
+ self.testCount++;
199
+ self.testTotal++;
200
+ if (self.setupSummary) { self.setupSummary.pass(); }
201
+ printLine(self.testTotal, 'pass', '');
202
+ return;
203
+ }
204
+
205
+ // Failed → attempt a fix.
206
+ self.testTotal++;
207
+ printLine(self.testTotal, 'warn', '— fixing…');
208
+
209
+ try {
210
+ await fix(self, args);
211
+ self.testCount++;
212
+ if (self.setupSummary) { self.setupSummary.pass(); }
213
+ ui.status('pass', chalk.green('fixed'), { level: 3 });
214
+ } catch (e) {
215
+ // The fix couldn't complete. `e.summaryDetails` (if present) is an array of
216
+ // pre-formatted detail lines the failing check wants surfaced in the summary.
217
+ const message = e && e.message ? e.message : String(e);
218
+ const details = (e && Array.isArray(e.summaryDetails)) ? e.summaryDetails : [chalk.red(message)];
219
+ ui.status('fail', chalk.red(`Could not fix: ${message}`), { level: 3 });
220
+ if (self.setupSummary) { self.setupSummary.fail(name, details); }
221
+
222
+ if (self.options['--continue']) {
223
+ ui.status('warn', chalk.yellow('Continuing despite error (--continue flag)'), { level: 3 });
224
+ return;
206
225
  }
207
- });
226
+
227
+ self.haltSetup();
228
+ }
229
+ };
230
+
231
+ // Halt the setup run on an unfixable failure. The failing check must already be
232
+ // recorded via `setupSummary.fail(...)` by the caller; this prints the summary
233
+ // block + a re-run hint and exits with code 1 — instead of rejecting a promise
234
+ // with no reason (which surfaced as an ugly `UnhandledPromiseRejection: undefined`).
235
+ Main.prototype.haltSetup = function() {
236
+ if (this.setupSummary) {
237
+ this.setupSummary.print({ hint: `Fix the above, then run ${require('chalk').default.bold('npx mgr setup')} again.` });
238
+ }
239
+
240
+ process.exit(1);
208
241
  };
209
242
 
210
243
  module.exports = Main;