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
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: mergeLineBasedFiles()
|
|
3
|
+
* Unit tests for the line-based .env / .gitignore / CLAUDE.md merge.
|
|
4
|
+
*
|
|
5
|
+
* Tests the pure function directly — no emulator, no Firestore, no HTTP.
|
|
6
|
+
*
|
|
7
|
+
* Regression coverage for the bug that scrambled consumers' `.env` files: an
|
|
8
|
+
* older positional implementation zipped comment lines and value lines by index,
|
|
9
|
+
* so any drift in key order shifted every value under the wrong header. The
|
|
10
|
+
* canonical impl merges BY KEY, so headers always re-anchor to the template and
|
|
11
|
+
* values follow their key across sections.
|
|
12
|
+
*/
|
|
13
|
+
const {
|
|
14
|
+
mergeLineBasedFiles,
|
|
15
|
+
DEFAULT_MARKER,
|
|
16
|
+
CUSTOM_MARKER,
|
|
17
|
+
} = require('../../src/utils/merge-line-files.js');
|
|
18
|
+
|
|
19
|
+
// Setup-test helper shim — must re-export the SAME canonical impl (SSOT).
|
|
20
|
+
const helperShim = require('../../src/cli/commands/setup-tests/helpers/merge-line-files.js');
|
|
21
|
+
|
|
22
|
+
// A representative framework .env template (keys grouped under headers).
|
|
23
|
+
const TEMPLATE = [
|
|
24
|
+
DEFAULT_MARKER,
|
|
25
|
+
'# GitHub',
|
|
26
|
+
'GH_TOKEN=""',
|
|
27
|
+
'',
|
|
28
|
+
'# AI',
|
|
29
|
+
'OPENAI_API_KEY=""',
|
|
30
|
+
'ANTHROPIC_API_KEY=""',
|
|
31
|
+
'',
|
|
32
|
+
'# Payment Processors',
|
|
33
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
34
|
+
'STRIPE_SECRET_KEY=""',
|
|
35
|
+
'',
|
|
36
|
+
CUSTOM_MARKER,
|
|
37
|
+
'# Add your custom environment variables below this line',
|
|
38
|
+
].join('\n');
|
|
39
|
+
|
|
40
|
+
// Pull the value assigned to a key in a given section of a merged result.
|
|
41
|
+
function keyValueInSection(merged, key, section /* 'default' | 'custom' */) {
|
|
42
|
+
const lines = merged.split('\n');
|
|
43
|
+
let mode = null;
|
|
44
|
+
for (const line of lines) {
|
|
45
|
+
const trimmed = line.trim();
|
|
46
|
+
if (trimmed === DEFAULT_MARKER) { mode = 'default'; continue; }
|
|
47
|
+
if (trimmed === CUSTOM_MARKER) { mode = 'custom'; continue; }
|
|
48
|
+
if (mode !== section || !trimmed || trimmed.startsWith('#')) continue;
|
|
49
|
+
const eq = trimmed.indexOf('=');
|
|
50
|
+
if (eq < 0) continue;
|
|
51
|
+
if (trimmed.slice(0, eq).trim() === key) return trimmed.slice(eq + 1);
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Count how many times a key appears anywhere (catches duplication bugs).
|
|
57
|
+
function keyOccurrences(merged, key) {
|
|
58
|
+
return merged.split('\n').filter((l) => l.trim().startsWith(`${key}=`)).length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Assert a key sits directly under the expected comment header in the merged output.
|
|
62
|
+
function headerAboveKey(merged, key) {
|
|
63
|
+
const lines = merged.split('\n');
|
|
64
|
+
const idx = lines.findIndex((l) => l.trim().startsWith(`${key}=`));
|
|
65
|
+
if (idx < 0) return null;
|
|
66
|
+
for (let i = idx - 1; i >= 0; i--) {
|
|
67
|
+
const t = lines[i].trim();
|
|
68
|
+
if (t.startsWith('#') && t !== DEFAULT_MARKER && t !== CUSTOM_MARKER) return t;
|
|
69
|
+
if (t && !t.startsWith('#')) continue; // another key — keep scanning up
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = {
|
|
75
|
+
description: 'mergeLineBasedFiles() line-based .env / .gitignore merge',
|
|
76
|
+
type: 'group',
|
|
77
|
+
|
|
78
|
+
tests: [
|
|
79
|
+
{
|
|
80
|
+
name: 'ssot-helper-shim-is-same-function',
|
|
81
|
+
async run({ assert }) {
|
|
82
|
+
assert.equal(
|
|
83
|
+
helperShim.mergeLineBasedFiles,
|
|
84
|
+
mergeLineBasedFiles,
|
|
85
|
+
'setup-tests helper must re-export the canonical mergeLineBasedFiles (SSOT)'
|
|
86
|
+
);
|
|
87
|
+
assert.equal(helperShim.DEFAULT_SECTION_MARKER, DEFAULT_MARKER, 'marker alias matches');
|
|
88
|
+
assert.equal(helperShim.CUSTOM_SECTION_MARKER, CUSTOM_MARKER, 'marker alias matches');
|
|
89
|
+
assert.ok(helperShim.hasSectionMarkers(TEMPLATE), 'hasSectionMarkers detects markers');
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
name: 'preserves-existing-values-by-key',
|
|
95
|
+
async run({ assert }) {
|
|
96
|
+
const existing = [
|
|
97
|
+
DEFAULT_MARKER,
|
|
98
|
+
'# GitHub',
|
|
99
|
+
'GH_TOKEN="ghp_real"',
|
|
100
|
+
'# AI',
|
|
101
|
+
'OPENAI_API_KEY="sk-real"',
|
|
102
|
+
'ANTHROPIC_API_KEY=""',
|
|
103
|
+
'# Payment Processors',
|
|
104
|
+
'PAYPAL_CLIENT_SECRET="pp_real"',
|
|
105
|
+
'STRIPE_SECRET_KEY=""',
|
|
106
|
+
CUSTOM_MARKER,
|
|
107
|
+
].join('\n');
|
|
108
|
+
|
|
109
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
110
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"ghp_real"', 'GH_TOKEN value kept');
|
|
111
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"sk-real"', 'OPENAI value kept');
|
|
112
|
+
assert.equal(keyValueInSection(merged, 'PAYPAL_CLIENT_SECRET', 'default'), '"pp_real"', 'PAYPAL value kept');
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
{
|
|
117
|
+
name: 'keys-stay-under-correct-header-when-order-drifts',
|
|
118
|
+
async run({ assert }) {
|
|
119
|
+
// Existing file has keys in a DIFFERENT order than the template. The old
|
|
120
|
+
// positional merge would shift values under the wrong header here.
|
|
121
|
+
const existing = [
|
|
122
|
+
DEFAULT_MARKER,
|
|
123
|
+
'# Misc (old grouping)',
|
|
124
|
+
'STRIPE_SECRET_KEY="stripe_real"',
|
|
125
|
+
'OPENAI_API_KEY="openai_real"',
|
|
126
|
+
'GH_TOKEN="gh_real"',
|
|
127
|
+
'PAYPAL_CLIENT_SECRET="pp_real"',
|
|
128
|
+
'ANTHROPIC_API_KEY="anthropic_real"',
|
|
129
|
+
CUSTOM_MARKER,
|
|
130
|
+
].join('\n');
|
|
131
|
+
|
|
132
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
133
|
+
|
|
134
|
+
assert.equal(headerAboveKey(merged, 'OPENAI_API_KEY'), '# AI', 'OPENAI under # AI');
|
|
135
|
+
assert.equal(headerAboveKey(merged, 'ANTHROPIC_API_KEY'), '# AI', 'ANTHROPIC under # AI');
|
|
136
|
+
assert.equal(headerAboveKey(merged, 'PAYPAL_CLIENT_SECRET'), '# Payment Processors', 'PAYPAL under # Payment Processors');
|
|
137
|
+
assert.equal(headerAboveKey(merged, 'STRIPE_SECRET_KEY'), '# Payment Processors', 'STRIPE under # Payment Processors');
|
|
138
|
+
assert.equal(headerAboveKey(merged, 'GH_TOKEN'), '# GitHub', 'GH_TOKEN under # GitHub');
|
|
139
|
+
|
|
140
|
+
// Values still attached to the right keys.
|
|
141
|
+
assert.equal(keyValueInSection(merged, 'STRIPE_SECRET_KEY', 'default'), '"stripe_real"', 'STRIPE value intact');
|
|
142
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"openai_real"', 'OPENAI value intact');
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
{
|
|
147
|
+
name: 'promotes-custom-key-to-default-when-template-adopts-it',
|
|
148
|
+
async run({ assert }) {
|
|
149
|
+
// User had APOLLO_API_KEY in Custom; framework adds it to the template.
|
|
150
|
+
const tplWithApollo = TEMPLATE.replace('STRIPE_SECRET_KEY=""', 'STRIPE_SECRET_KEY=""\nAPOLLO_API_KEY=""');
|
|
151
|
+
const existing = [
|
|
152
|
+
DEFAULT_MARKER,
|
|
153
|
+
'# GitHub',
|
|
154
|
+
'GH_TOKEN="gh_real"',
|
|
155
|
+
'# AI',
|
|
156
|
+
'OPENAI_API_KEY=""',
|
|
157
|
+
'ANTHROPIC_API_KEY=""',
|
|
158
|
+
'# Payment Processors',
|
|
159
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
160
|
+
'STRIPE_SECRET_KEY=""',
|
|
161
|
+
CUSTOM_MARKER,
|
|
162
|
+
'APOLLO_API_KEY="apollo_real"',
|
|
163
|
+
'GITHUB_TOKEN="legacy"',
|
|
164
|
+
].join('\n');
|
|
165
|
+
|
|
166
|
+
const merged = mergeLineBasedFiles(existing, tplWithApollo, '.env');
|
|
167
|
+
|
|
168
|
+
assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'default'), '"apollo_real"', 'APOLLO promoted to Default with value');
|
|
169
|
+
assert.equal(keyValueInSection(merged, 'APOLLO_API_KEY', 'custom'), undefined, 'APOLLO removed from Custom');
|
|
170
|
+
assert.equal(keyOccurrences(merged, 'APOLLO_API_KEY'), 1, 'APOLLO not duplicated');
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
name: 'migrates-unknown-default-key-to-custom',
|
|
176
|
+
async run({ assert }) {
|
|
177
|
+
// User has a legacy key in Default that the template no longer defines.
|
|
178
|
+
const existing = [
|
|
179
|
+
DEFAULT_MARKER,
|
|
180
|
+
'# GitHub',
|
|
181
|
+
'GITHUB_TOKEN="legacy_real"', // template now uses GH_TOKEN
|
|
182
|
+
'GH_TOKEN="gh_real"',
|
|
183
|
+
'# AI',
|
|
184
|
+
'OPENAI_API_KEY=""',
|
|
185
|
+
'ANTHROPIC_API_KEY=""',
|
|
186
|
+
'# Payment Processors',
|
|
187
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
188
|
+
'STRIPE_SECRET_KEY=""',
|
|
189
|
+
CUSTOM_MARKER,
|
|
190
|
+
].join('\n');
|
|
191
|
+
|
|
192
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
193
|
+
|
|
194
|
+
assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'custom'), '"legacy_real"', 'legacy key migrated to Custom with value');
|
|
195
|
+
assert.equal(keyValueInSection(merged, 'GITHUB_TOKEN', 'default'), undefined, 'legacy key gone from Default');
|
|
196
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"gh_real"', 'GH_TOKEN kept in Default');
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
{
|
|
201
|
+
name: 'preserves-custom-section-verbatim',
|
|
202
|
+
async run({ assert }) {
|
|
203
|
+
const existing = [
|
|
204
|
+
DEFAULT_MARKER,
|
|
205
|
+
'# GitHub',
|
|
206
|
+
'GH_TOKEN=""',
|
|
207
|
+
'# AI',
|
|
208
|
+
'OPENAI_API_KEY=""',
|
|
209
|
+
'ANTHROPIC_API_KEY=""',
|
|
210
|
+
'# Payment Processors',
|
|
211
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
212
|
+
'STRIPE_SECRET_KEY=""',
|
|
213
|
+
CUSTOM_MARKER,
|
|
214
|
+
'MY_CUSTOM_KEY="custom_real"',
|
|
215
|
+
'# OAuth2',
|
|
216
|
+
'OAUTH_ID="abc"',
|
|
217
|
+
].join('\n');
|
|
218
|
+
|
|
219
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
220
|
+
assert.equal(keyValueInSection(merged, 'MY_CUSTOM_KEY', 'custom'), '"custom_real"', 'custom key preserved');
|
|
221
|
+
assert.equal(keyValueInSection(merged, 'OAUTH_ID', 'custom'), '"abc"', 'custom OAuth key preserved');
|
|
222
|
+
assert.ok(merged.includes('# OAuth2'), 'custom comment preserved');
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
{
|
|
227
|
+
name: 'normalizes-raw-values-to-double-quoted',
|
|
228
|
+
async run({ assert }) {
|
|
229
|
+
const existing = [
|
|
230
|
+
DEFAULT_MARKER,
|
|
231
|
+
'# GitHub',
|
|
232
|
+
'GH_TOKEN=raw-no-quotes',
|
|
233
|
+
'# AI',
|
|
234
|
+
"OPENAI_API_KEY='single'",
|
|
235
|
+
'ANTHROPIC_API_KEY=""',
|
|
236
|
+
'# Payment Processors',
|
|
237
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
238
|
+
'STRIPE_SECRET_KEY=""',
|
|
239
|
+
CUSTOM_MARKER,
|
|
240
|
+
].join('\n');
|
|
241
|
+
|
|
242
|
+
const merged = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
243
|
+
assert.equal(keyValueInSection(merged, 'GH_TOKEN', 'default'), '"raw-no-quotes"', 'raw value double-quoted');
|
|
244
|
+
assert.equal(keyValueInSection(merged, 'OPENAI_API_KEY', 'default'), '"single"', 'single-quoted canonicalized to double');
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
name: 'is-idempotent',
|
|
250
|
+
async run({ assert }) {
|
|
251
|
+
const existing = [
|
|
252
|
+
DEFAULT_MARKER,
|
|
253
|
+
'# GitHub',
|
|
254
|
+
'GH_TOKEN="gh_real"',
|
|
255
|
+
'# AI',
|
|
256
|
+
'OPENAI_API_KEY="sk"',
|
|
257
|
+
'ANTHROPIC_API_KEY=""',
|
|
258
|
+
'# Payment Processors',
|
|
259
|
+
'PAYPAL_CLIENT_SECRET=""',
|
|
260
|
+
'STRIPE_SECRET_KEY=""',
|
|
261
|
+
CUSTOM_MARKER,
|
|
262
|
+
'CUSTOM="x"',
|
|
263
|
+
].join('\n');
|
|
264
|
+
|
|
265
|
+
const once = mergeLineBasedFiles(existing, TEMPLATE, '.env');
|
|
266
|
+
const twice = mergeLineBasedFiles(once, TEMPLATE, '.env');
|
|
267
|
+
assert.equal(once, twice, 're-running the merge produces identical output');
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
@@ -446,11 +446,20 @@ module.exports = {
|
|
|
446
446
|
async run({ http, firestore, assert, accounts }) {
|
|
447
447
|
const uid = accounts['signup-merge'].uid;
|
|
448
448
|
|
|
449
|
-
//
|
|
450
|
-
//
|
|
449
|
+
// Capture the REAL api keys onCreate generated — the signup write must preserve these
|
|
450
|
+
// exactly (regenerating them would break the user's API access AND this test's auth,
|
|
451
|
+
// since http.as() authenticates with api.privateKey). We assert against the real values,
|
|
452
|
+
// NOT a seeded fake (seeding a fake privateKey would 401 the request).
|
|
453
|
+
const before = await firestore.get(`users/${uid}`);
|
|
454
|
+
const realClientId = before?.api?.clientId;
|
|
455
|
+
const realPrivateKey = before?.api?.privateKey;
|
|
456
|
+
const realAffiliateCode = before?.affiliate?.code;
|
|
457
|
+
|
|
458
|
+
// Seed adversarial NON-auth state the signup write must NOT clobber: a paid subscription,
|
|
459
|
+
// admin role, a custom non-schema field, and a deliberately PARTIAL attribution (only
|
|
460
|
+
// affiliate.code) to prove leaves get filled, not replaced-away. We do NOT touch api.* —
|
|
461
|
+
// that's the auth credential and is asserted-preserved via the captured real values.
|
|
451
462
|
await firestore.set(`users/${uid}`, {
|
|
452
|
-
api: { clientId: 'REAL-CLIENT-ID', privateKey: 'REAL-PRIVATE-KEY' },
|
|
453
|
-
affiliate: { code: 'REALAFFILIATECODE', referrals: [{ uid: 'someone' }] },
|
|
454
463
|
subscription: { product: { id: 'pro', name: 'Pro' }, status: 'active' },
|
|
455
464
|
roles: { admin: true, betaTester: false, developer: false },
|
|
456
465
|
attribution: { affiliate: { code: 'PARTIALONLY' } },
|
|
@@ -466,9 +475,9 @@ module.exports = {
|
|
|
466
475
|
const doc = await firestore.get(`users/${uid}`);
|
|
467
476
|
|
|
468
477
|
// (b) Existing real values must survive untouched — NOT regenerated/reset by the schema layer.
|
|
469
|
-
assert.equal(doc?.api?.clientId,
|
|
470
|
-
assert.equal(doc?.api?.privateKey,
|
|
471
|
-
assert.equal(doc?.affiliate?.code,
|
|
478
|
+
assert.equal(doc?.api?.clientId, realClientId, 'api.clientId must be preserved (not regenerated)');
|
|
479
|
+
assert.equal(doc?.api?.privateKey, realPrivateKey, 'api.privateKey must be preserved (not regenerated)');
|
|
480
|
+
assert.equal(doc?.affiliate?.code, realAffiliateCode, 'affiliate.code must be preserved (not regenerated)');
|
|
472
481
|
assert.equal(doc?.subscription?.product?.id, 'pro', 'subscription must be preserved (not reset to basic)');
|
|
473
482
|
assert.equal(doc?.roles?.admin, true, 'roles.admin must be preserved (not reset to false)');
|
|
474
483
|
|