backend-manager 5.2.17 → 5.2.19
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 +16 -0
- package/CLAUDE.md +2 -1
- package/bin/backend-manager +10 -1
- package/docs/cli-output.md +122 -0
- package/docs/testing.md +5 -3
- package/package.json +1 -1
- package/src/cli/commands/base-command.js +4 -0
- 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 +53 -29
- package/src/cli/index.js +68 -39
- package/src/cli/utils/ui.js +270 -0
- package/src/manager/libraries/email/data/disposable-domains.json +1 -0
- package/src/utils/merge-line-files.js +16 -5
- package/templates/_.env +1 -0
- package/test/helpers/merge-line-files.js +271 -0
- package/test/routes/user/signup.js +16 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
+
ui.status('warn', `Attempt ${attempt}/${maxAttempts} had failures — retrying…`);
|
|
46
44
|
} else if (maxAttempts > 1) {
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
//
|
|
128
|
+
// Warn if using local backend-manager
|
|
118
129
|
if (self.package.dependencies['backend-manager'].includes('file:')) {
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
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,6 +186,9 @@ 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) {
|
|
@@ -201,10 +215,12 @@ class SetupCommand extends BaseCommand {
|
|
|
201
215
|
const merged = mergeLineBasedFiles(existing, incoming, basename);
|
|
202
216
|
if (merged !== existing) {
|
|
203
217
|
jetpack.write(dest, merged);
|
|
204
|
-
|
|
218
|
+
ui.status('change', `Merged ${chalk.cyan(target)}`, { level: 2 });
|
|
219
|
+
touched++;
|
|
205
220
|
}
|
|
206
221
|
} catch (e) {
|
|
207
|
-
|
|
222
|
+
ui.status('warn', `Failed to merge ${chalk.cyan(target)}`, { detail: e.message, level: 2 });
|
|
223
|
+
touched++;
|
|
208
224
|
}
|
|
209
225
|
continue;
|
|
210
226
|
}
|
|
@@ -215,7 +231,12 @@ class SetupCommand extends BaseCommand {
|
|
|
215
231
|
|
|
216
232
|
// First time: copy as-is.
|
|
217
233
|
jetpack.copy(src, dest);
|
|
218
|
-
|
|
234
|
+
ui.status('add', `Copied ${chalk.cyan(target)}`, { level: 2 });
|
|
235
|
+
touched++;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (touched === 0) {
|
|
239
|
+
ui.note('All defaults up to date', 2);
|
|
219
240
|
}
|
|
220
241
|
}
|
|
221
242
|
|
|
@@ -281,12 +302,15 @@ class SetupCommand extends BaseCommand {
|
|
|
281
302
|
.then(json => json)
|
|
282
303
|
.catch(e => e);
|
|
283
304
|
|
|
305
|
+
const ui = this.ui;
|
|
284
306
|
if (statsFetchResult instanceof Error) {
|
|
285
|
-
if (
|
|
286
|
-
|
|
307
|
+
if (statsFetchResult.message.includes('network timeout')) {
|
|
308
|
+
ui.status('skip', 'Skipped stats fetch', { detail: 'network timeout', level: 2 });
|
|
309
|
+
} else {
|
|
310
|
+
ui.status('warn', 'Could not fetch stats endpoint', { detail: statsFetchResult.message, level: 2 });
|
|
287
311
|
}
|
|
288
312
|
} else {
|
|
289
|
-
|
|
313
|
+
ui.status('pass', 'Stats fetched/created', { level: 2 });
|
|
290
314
|
}
|
|
291
315
|
}
|
|
292
316
|
}
|
package/src/cli/index.js
CHANGED
|
@@ -164,47 +164,76 @@ 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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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).
|
|
172
|
+
const printLine = (index, kind, label) => {
|
|
173
|
+
const colorByKind = { pass: chalk.green, fail: chalk.red, warn: chalk.yellow };
|
|
174
|
+
const color = colorByKind[kind] || chalk.white;
|
|
175
|
+
const suffix = label ? ` ${chalk.dim(label)}` : '';
|
|
176
|
+
console.log(`${ui.indent(2)}${chalk.dim(`[${index}]`)} ${color(ui.SYMBOLS[kind])} ${name}${suffix}`);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
let passed = await fn();
|
|
180
|
+
|
|
181
|
+
// A check that returns an Error is a hard, unrecoverable failure (e.g. wrong
|
|
182
|
+
// Node version). Print it cleanly and stop — no auto-fix is possible.
|
|
183
|
+
if (passed instanceof Error) {
|
|
184
|
+
self.testTotal++;
|
|
185
|
+
printLine(self.testTotal, 'fail', '');
|
|
186
|
+
const message = passed.message || String(passed);
|
|
187
|
+
ui.status('fail', chalk.red(message), { level: 3 });
|
|
188
|
+
if (self.setupSummary) { self.setupSummary.fail(name, [chalk.red(message)]); }
|
|
189
|
+
self.haltSetup();
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (passed) {
|
|
194
|
+
self.testCount++;
|
|
195
|
+
self.testTotal++;
|
|
196
|
+
if (self.setupSummary) { self.setupSummary.pass(); }
|
|
197
|
+
printLine(self.testTotal, 'pass', '');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Failed → attempt a fix.
|
|
202
|
+
self.testTotal++;
|
|
203
|
+
printLine(self.testTotal, 'warn', '— fixing…');
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
await fix(self, args);
|
|
207
|
+
self.testCount++;
|
|
208
|
+
if (self.setupSummary) { self.setupSummary.pass(); }
|
|
209
|
+
ui.status('pass', chalk.green('fixed'), { level: 3 });
|
|
210
|
+
} catch (e) {
|
|
211
|
+
// The fix couldn't complete. `e.summaryDetails` (if present) is an array of
|
|
212
|
+
// pre-formatted detail lines the failing check wants surfaced in the summary.
|
|
213
|
+
const message = e && e.message ? e.message : String(e);
|
|
214
|
+
const details = (e && Array.isArray(e.summaryDetails)) ? e.summaryDetails : [chalk.red(message)];
|
|
215
|
+
ui.status('fail', chalk.red(`Could not fix: ${message}`), { level: 3 });
|
|
216
|
+
if (self.setupSummary) { self.setupSummary.fail(name, details); }
|
|
217
|
+
|
|
218
|
+
if (self.options['--continue']) {
|
|
219
|
+
ui.status('warn', chalk.yellow('Continuing despite error (--continue flag)'), { level: 3 });
|
|
220
|
+
return;
|
|
206
221
|
}
|
|
207
|
-
|
|
222
|
+
|
|
223
|
+
self.haltSetup();
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// Halt the setup run on an unfixable failure. The failing check must already be
|
|
228
|
+
// recorded via `setupSummary.fail(...)` by the caller; this prints the summary
|
|
229
|
+
// block + a re-run hint and exits with code 1 — instead of rejecting a promise
|
|
230
|
+
// with no reason (which surfaced as an ugly `UnhandledPromiseRejection: undefined`).
|
|
231
|
+
Main.prototype.haltSetup = function() {
|
|
232
|
+
if (this.setupSummary) {
|
|
233
|
+
this.setupSummary.print({ hint: `Fix the above, then run ${require('chalk').default.bold('npx mgr setup')} again.` });
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
process.exit(1);
|
|
208
237
|
};
|
|
209
238
|
|
|
210
239
|
module.exports = Main;
|
|
@@ -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
|
+
};
|
|
@@ -61,8 +61,10 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
|
|
|
61
61
|
continue;
|
|
62
62
|
}
|
|
63
63
|
if (existingCustomKeys.has(key)) {
|
|
64
|
-
//
|
|
65
|
-
|
|
64
|
+
// The framework newly adopted a key the user had in their Custom section.
|
|
65
|
+
// Promote it UP into Default with the user's value, and drop the Custom copy
|
|
66
|
+
// (handled below) so the key isn't duplicated / left empty.
|
|
67
|
+
emit(findKeyLine(existingCustom, key));
|
|
66
68
|
continue;
|
|
67
69
|
}
|
|
68
70
|
}
|
|
@@ -97,10 +99,19 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
|
|
|
97
99
|
}
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
// The user's Custom section is preserved
|
|
101
|
-
//
|
|
102
|
+
// The user's Custom section is preserved — except (a) .env values get normalized
|
|
103
|
+
// to double-quoted form for consistent quoting, and (b) any key the framework just
|
|
104
|
+
// adopted into its Default section is dropped here, since it was promoted UP into
|
|
105
|
+
// Default above (otherwise the key would appear in both sections).
|
|
102
106
|
const finalCustom = isEnvFile
|
|
103
|
-
? existingCustom
|
|
107
|
+
? existingCustom
|
|
108
|
+
.filter((line) => {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed || trimmed.startsWith('#')) return true;
|
|
111
|
+
const key = trimmed.split('=')[0].trim();
|
|
112
|
+
return !(key && newDefaultKeys.has(key));
|
|
113
|
+
})
|
|
114
|
+
.map((line) => normalizeEnvLine(line))
|
|
104
115
|
: existingCustom;
|
|
105
116
|
|
|
106
117
|
const result = [];
|