backend-manager 5.0.200 → 5.0.202
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
CHANGED
|
@@ -14,6 +14,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.202] - 2026-05-12
|
|
18
|
+
### Added
|
|
19
|
+
- **`src/defaults/CLAUDE.md`** — new file shipped to consumer projects, marker-wrapped with `# ========== Default Values ==========` / `# ========== Custom Values ==========`. Framework section stays live-synced across `npx mgr setup` while the Custom section is preserved verbatim. Aligns BEM with EM/BXM/UJM, which already ship a consumer-facing default CLAUDE.md.
|
|
20
|
+
- **`src/utils/merge-line-files.js`** — new file. Implements the line-based merge for the marker-wrapped Default/Custom sections (copied verbatim from `electron-manager`'s utility). Reusable across `.env`, `.gitignore`, `CLAUDE.md`, and any other line-based files BEM might ship in the future.
|
|
21
|
+
- **`copyDefaults()` method** in `src/cli/commands/setup.js`. New defaults-shipping mechanism for BEM (which previously had no `src/defaults/` directory at all). Mirrors EM's `copyDefaults` pattern: iterates `src/defaults/**`, renames `_.foo` → `.foo`, routes files matching `MERGEABLE_BASENAMES` (`['.env', '.gitignore', 'CLAUDE.md']`) through `mergeLineBasedFiles`, copies non-mergeable files only if they don't already exist. Wired into `runSetup()` BEFORE `runTests()` so test inspections see the merged state.
|
|
22
|
+
|
|
23
|
+
# [5.0.201] - 2026-05-08
|
|
24
|
+
### Changed
|
|
25
|
+
- Account deletion confirmation email now includes a "Deletion details:" block with the user's account email, UID, and deletion timestamp (UTC) — matching the pattern used in the data-request download email.
|
|
26
|
+
|
|
17
27
|
# [5.0.200] - 2026-05-05
|
|
18
28
|
### Changed
|
|
19
29
|
- Bumped `uuid` from `^13.0.2` to `^14.0.0`. uuid v14 is ESM-only, but Node 22+'s native `require(esm)` support means existing CommonJS call sites (`require('uuid').v4`, `.v5`, etc.) work unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "backend-manager",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.202",
|
|
4
4
|
"description": "Quick tools for developing Firebase functions",
|
|
5
5
|
"main": "src/manager/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
}
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@firebase/rules-unit-testing": "^5.0.
|
|
52
|
+
"@firebase/rules-unit-testing": "^5.0.1",
|
|
53
53
|
"@google-cloud/firestore": "^7.11.6",
|
|
54
54
|
"@google-cloud/pubsub": "^5.3.0",
|
|
55
55
|
"@google-cloud/storage": "^7.19.0",
|
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
58
58
|
"@octokit/rest": "^22.0.1",
|
|
59
59
|
"@sendgrid/mail": "^8.1.6",
|
|
60
|
-
"@sentry/node": "^10.
|
|
60
|
+
"@sentry/node": "^10.52.0",
|
|
61
61
|
"body-parser": "^2.2.2",
|
|
62
62
|
"busboy": "^1.6.0",
|
|
63
63
|
"chalk": "^5.6.2",
|
|
@@ -81,7 +81,7 @@
|
|
|
81
81
|
"npm-api": "^1.0.1",
|
|
82
82
|
"pushid": "^1.0.0",
|
|
83
83
|
"sanitize-html": "^2.17.3",
|
|
84
|
-
"stripe": "^22.1.
|
|
84
|
+
"stripe": "^22.1.1",
|
|
85
85
|
"uid-generator": "^2.0.0",
|
|
86
86
|
"uuid": "^14.0.0",
|
|
87
87
|
"wonderful-fetch": "^2.0.5",
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"prepare-package": "^2.1.0"
|
|
95
95
|
},
|
|
96
96
|
"peerDependencies": {
|
|
97
|
-
"firebase-admin": "^13.
|
|
97
|
+
"firebase-admin": "^13.9.0",
|
|
98
98
|
"firebase-functions": "^7.2.5"
|
|
99
99
|
}
|
|
100
100
|
}
|
|
@@ -107,6 +107,10 @@ class SetupCommand extends BaseCommand {
|
|
|
107
107
|
// Clean up leftover trigger files from watch command
|
|
108
108
|
this.cleanupTriggerFiles();
|
|
109
109
|
|
|
110
|
+
// Copy / merge defaults into consumer project root (matches EM/BXM/UJM pattern).
|
|
111
|
+
// Runs BEFORE tests so any test that inspects scaffolded files sees the merged state.
|
|
112
|
+
this.copyDefaults();
|
|
113
|
+
|
|
110
114
|
// Run all tests
|
|
111
115
|
await this.runTests();
|
|
112
116
|
|
|
@@ -147,6 +151,74 @@ class SetupCommand extends BaseCommand {
|
|
|
147
151
|
self.default.databaseRulesCore = self.default.databaseRulesWhole.match(bem_allRulesRegex)[0];
|
|
148
152
|
}
|
|
149
153
|
|
|
154
|
+
// Copy default files (src/defaults/**) into the consumer project root.
|
|
155
|
+
// For files in MERGEABLE_BASENAMES, route through `mergeLineBasedFiles` so the
|
|
156
|
+
// framework's section stays live-synced while the consumer's Custom section is
|
|
157
|
+
// preserved verbatim. For non-mergeable files: copy on first setup, skip if exists.
|
|
158
|
+
//
|
|
159
|
+
// Mirrors EM's copyDefaults pattern (src/commands/setup.js in electron-manager).
|
|
160
|
+
// Same marker convention as .env/.gitignore in EM/BXM/UJM:
|
|
161
|
+
// # ========== Default Values ========== (framework-owned)
|
|
162
|
+
// # ========== Custom Values ========== (consumer-owned)
|
|
163
|
+
copyDefaults() {
|
|
164
|
+
const self = this.main;
|
|
165
|
+
const defaultsDir = path.resolve(`${__dirname}/../../defaults`);
|
|
166
|
+
|
|
167
|
+
if (!jetpack.exists(defaultsDir)) {
|
|
168
|
+
// Defaults dir is optional — older BEM versions didn't have one. If missing, skip silently.
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { mergeLineBasedFiles } = require('../../utils/merge-line-files.js');
|
|
173
|
+
// Files routed through the marker-based merge (vs verbatim copy / skip-if-exists).
|
|
174
|
+
// .env / .gitignore aren't currently shipped by BEM but are included here so this
|
|
175
|
+
// matches the EM/BXM/UJM contract if we ever add them.
|
|
176
|
+
const MERGEABLE_BASENAMES = new Set(['.env', '.gitignore', 'CLAUDE.md']);
|
|
177
|
+
|
|
178
|
+
const files = jetpack.find(defaultsDir, { matching: '**/*', recursive: true, files: true, directories: false });
|
|
179
|
+
|
|
180
|
+
for (const src of files) {
|
|
181
|
+
const rel = path.relative(defaultsDir, src);
|
|
182
|
+
const segments = rel.split(path.sep);
|
|
183
|
+
|
|
184
|
+
// Skip "archive" directories — anything 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('_.'))) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Convert leading `_.` to `.` so dotfiles ship past npm's filter.
|
|
192
|
+
const target = segments.map((part) => part.startsWith('_.') ? part.slice(1) : part).join(path.sep);
|
|
193
|
+
const dest = path.join(self.firebaseProjectPath, target);
|
|
194
|
+
const basename = path.basename(target);
|
|
195
|
+
|
|
196
|
+
if (jetpack.exists(dest)) {
|
|
197
|
+
if (MERGEABLE_BASENAMES.has(basename)) {
|
|
198
|
+
try {
|
|
199
|
+
const existing = jetpack.read(dest, 'utf8');
|
|
200
|
+
const incoming = jetpack.read(src, 'utf8');
|
|
201
|
+
const merged = mergeLineBasedFiles(existing, incoming, basename);
|
|
202
|
+
if (merged !== existing) {
|
|
203
|
+
jetpack.write(dest, merged);
|
|
204
|
+
this.logSuccess(`Merged default → ${target}`);
|
|
205
|
+
}
|
|
206
|
+
} catch (e) {
|
|
207
|
+
this.logWarning(`Failed to merge ${target}: ${e.message}`);
|
|
208
|
+
}
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Non-mergeable, already exists → preserve consumer's version.
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// First time: copy as-is.
|
|
217
|
+
jetpack.copy(src, dest);
|
|
218
|
+
this.logSuccess(`Copied default → ${target}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
150
222
|
cleanupTriggerFiles() {
|
|
151
223
|
const self = this.main;
|
|
152
224
|
const triggerFile = `${self.firebaseProjectPath}/functions/bem-reload-trigger.js`;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# ========== Default Values ==========
|
|
2
|
+
# Backend Manager (BEM) — consumer project
|
|
3
|
+
|
|
4
|
+
> **Auto-managed file.** Everything between `# ========== Default Values ==========` and `# ========== Custom Values ==========` is owned by `backend-manager` and rewritten on every `npx mgr setup`. Put your own project-specific notes BELOW the `Custom Values` marker — that section is preserved verbatim across setups.
|
|
5
|
+
|
|
6
|
+
## Framework
|
|
7
|
+
|
|
8
|
+
This project consumes **Backend Manager** (BEM) — a comprehensive framework for building modern Firebase Cloud Functions backends. BEM provides a single `Manager.init(exports, {...})` bootstrap that wires built-in functions (`bm_api`, auth events, cron jobs), helper classes (Assistant, User, Analytics, Usage, Middleware, Settings, Utilities, Metadata), payment processor integrations (Stripe / PayPal), Firestore-trigger pipelines, and a deploy/emulator/watch tooling pipeline.
|
|
9
|
+
|
|
10
|
+
**Framework's own docs** (read these for deep-dives; both paths point to the same files, the absolute path works regardless of working directory):
|
|
11
|
+
- Top-level overview: `/Users/ian/Developer/Repositories/ITW-Creative-Works/backend-manager/CLAUDE.md` (or `node_modules/backend-manager/CLAUDE.md`)
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
cd functions
|
|
17
|
+
npx mgr setup # validate config + scaffold defaults + run checks
|
|
18
|
+
npx mgr emulator # start Firebase emulators (auth/firestore/functions/database/storage)
|
|
19
|
+
npx mgr watch # auto-reload functions on file change
|
|
20
|
+
npx mgr deploy # deploy to Firebase
|
|
21
|
+
npx mgr logs # tail Cloud Functions logs
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
All `npx mgr <cmd>` aliases — `npx bm <cmd>`, `npx bem <cmd>`, `npx backend-manager <cmd>` work too.
|
|
25
|
+
|
|
26
|
+
## Where things live
|
|
27
|
+
|
|
28
|
+
- `functions/index.js` — entry point. Must call `Manager.init(exports, { ... })` to register all built-in + custom endpoints.
|
|
29
|
+
- `functions/backend-manager-config.json` — BEM config: brand, projectType (`firebase` or `custom`), feature flags, rate limits, hooks.
|
|
30
|
+
- `functions/.env` — secrets (BACKEND_MANAGER_KEY, third-party API keys). Gitignored.
|
|
31
|
+
- `functions/service-account.json` — Firebase Admin credentials. Gitignored.
|
|
32
|
+
- `functions/routes/<verb>/<path>.js` — custom routes mounted at runtime (e.g. `routes/get/hello.js` → `GET /hello`).
|
|
33
|
+
- `functions/schemas/<name>.js` — schema definitions for `Manager.Settings()` validation.
|
|
34
|
+
- `firebase.json` — Firebase config (hosting, rewrites, emulator ports). Some fields managed by `npx mgr setup`.
|
|
35
|
+
- `.firebaserc` — Firebase project ID alias.
|
|
36
|
+
- `firestore.rules` / `database.rules.json` — security rules. BEM owns a `///---backend-manager---///` block inside each; everything outside is yours.
|
|
37
|
+
|
|
38
|
+
## Per-context imports
|
|
39
|
+
|
|
40
|
+
```js
|
|
41
|
+
// functions/index.js — the entire backend bootstrap
|
|
42
|
+
const Manager = require('backend-manager');
|
|
43
|
+
Manager.init(exports, {
|
|
44
|
+
projectType: 'firebase',
|
|
45
|
+
// ...your config
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// In a custom route (functions/routes/get/hello.js):
|
|
49
|
+
module.exports = async function(Manager, assistant) {
|
|
50
|
+
// assistant.req, assistant.res, assistant.user, etc.
|
|
51
|
+
};
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Available APIs at runtime
|
|
55
|
+
|
|
56
|
+
After `Manager.init()`, the Manager instance exposes factory methods:
|
|
57
|
+
- `Manager.Assistant({ req, res })` — request handler with user + analytics + utility access
|
|
58
|
+
- `Manager.User(data)` — user property structure + schema
|
|
59
|
+
- `Manager.Analytics({ assistant })` — GA4 event tracking
|
|
60
|
+
- `Manager.Usage()` — rate-limiting
|
|
61
|
+
- `Manager.Middleware(req, res)` — request pipeline
|
|
62
|
+
- `Manager.Settings()` — schema validation against `functions/schemas/*`
|
|
63
|
+
- `Manager.Utilities()` — batch operations + helpers
|
|
64
|
+
- `Manager.Metadata(doc)` — timestamps + tag helpers
|
|
65
|
+
- `Manager.storage({ name })` — local JSON storage (lowdb)
|
|
66
|
+
|
|
67
|
+
Auth events, payment-webhook transitions, and cron jobs are wired automatically — hook into them by exporting from `functions/hooks/<area>/<event>.js`.
|
|
68
|
+
|
|
69
|
+
# ========== Custom Values ==========
|
|
70
|
+
|
|
71
|
+
## Project-specific notes
|
|
72
|
+
|
|
73
|
+
Add anything specific to THIS project here. Edits below this line are preserved across `npx mgr setup` runs.
|
|
@@ -1109,6 +1109,7 @@
|
|
|
1109
1109
|
"dealja.com",
|
|
1110
1110
|
"deallabs.org",
|
|
1111
1111
|
"dealrek.com",
|
|
1112
|
+
"deapad.com",
|
|
1112
1113
|
"decep.com",
|
|
1113
1114
|
"decodewp.com",
|
|
1114
1115
|
"dede.infos.st",
|
|
@@ -1376,6 +1377,9 @@
|
|
|
1376
1377
|
"edudingy.cfd",
|
|
1377
1378
|
"edumail.edu.pl",
|
|
1378
1379
|
"edumail.edu.rs",
|
|
1380
|
+
"edumaili.com",
|
|
1381
|
+
"edumaili.edu.pl",
|
|
1382
|
+
"edumaill.edu.pl",
|
|
1379
1383
|
"edupolska.edu.pl",
|
|
1380
1384
|
"edv.to",
|
|
1381
1385
|
"ee1.pl",
|
|
@@ -2460,6 +2464,7 @@
|
|
|
2460
2464
|
"javadmin.com",
|
|
2461
2465
|
"javaemail.com",
|
|
2462
2466
|
"jbsze.com",
|
|
2467
|
+
"jbsze.ne",
|
|
2463
2468
|
"jcnorris.com",
|
|
2464
2469
|
"jdmadventures.com",
|
|
2465
2470
|
"jdz.ro",
|
|
@@ -2908,6 +2913,7 @@
|
|
|
2908
2913
|
"maileater.com",
|
|
2909
2914
|
"mailed.in",
|
|
2910
2915
|
"mailed.ro",
|
|
2916
|
+
"mailedu.edu.pl",
|
|
2911
2917
|
"maileimer.de",
|
|
2912
2918
|
"maileme101.com",
|
|
2913
2919
|
"mailer.edu.pl",
|
|
@@ -3171,6 +3177,7 @@
|
|
|
3171
3177
|
"mliok.com",
|
|
3172
3178
|
"mm.my",
|
|
3173
3179
|
"mm5.se",
|
|
3180
|
+
"mmaily.com",
|
|
3174
3181
|
"mnode.me",
|
|
3175
3182
|
"moakt.cc",
|
|
3176
3183
|
"moakt.co",
|
|
@@ -81,7 +81,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
81
81
|
// Send confirmation email (fire-and-forget)
|
|
82
82
|
const shouldSend = !assistant.isTesting() || process.env.TEST_EXTENDED_MODE;
|
|
83
83
|
if (email && shouldSend) {
|
|
84
|
-
sendConfirmationEmail(assistant, email, reason, userData?.personal?.name?.first);
|
|
84
|
+
sendConfirmationEmail(assistant, email, uid, reason, userData?.personal?.name?.first);
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
return assistant.respond({ success: true });
|
|
@@ -90,7 +90,7 @@ module.exports = async ({ assistant, Manager, user, settings, libraries }) => {
|
|
|
90
90
|
/**
|
|
91
91
|
* Send account deletion confirmation email (fire-and-forget)
|
|
92
92
|
*/
|
|
93
|
-
function sendConfirmationEmail(assistant, email, reason, firstName) {
|
|
93
|
+
function sendConfirmationEmail(assistant, email, uid, reason, firstName) {
|
|
94
94
|
const Manager = assistant.Manager;
|
|
95
95
|
const brandName = Manager.config.brand.name;
|
|
96
96
|
const mailer = Manager.Email(assistant);
|
|
@@ -98,6 +98,7 @@ function sendConfirmationEmail(assistant, email, reason, firstName) {
|
|
|
98
98
|
const reasonLine = reason
|
|
99
99
|
? `\n\n**Reason provided:** ${reason}`
|
|
100
100
|
: '';
|
|
101
|
+
const deletionDate = new Date().toUTCString();
|
|
101
102
|
|
|
102
103
|
mailer.send({
|
|
103
104
|
to: email,
|
|
@@ -114,6 +115,12 @@ function sendConfirmationEmail(assistant, email, reason, firstName) {
|
|
|
114
115
|
title: 'Account Deleted',
|
|
115
116
|
message: `${greeting} **${brandName}** account and all associated personal data have been permanently deleted from our systems. This action is irreversible.${reasonLine}
|
|
116
117
|
|
|
118
|
+
**Deletion details:**
|
|
119
|
+
|
|
120
|
+
- **Account email:** ${email}
|
|
121
|
+
- **Account UID:** ${uid}
|
|
122
|
+
- **Deletion date:** ${deletionDate}
|
|
123
|
+
|
|
117
124
|
**What this means:**
|
|
118
125
|
|
|
119
126
|
- Your account credentials and profile information have been removed.
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// Merge line-based files (.env, .gitignore, CLAUDE.md) on `npx mgr setup`.
|
|
2
|
+
//
|
|
3
|
+
// Convention (matches EM/BXM/UJM):
|
|
4
|
+
//
|
|
5
|
+
// # ========== Default Values ==========
|
|
6
|
+
// # framework-managed; overwritten on every setup
|
|
7
|
+
// KEY1=
|
|
8
|
+
// KEY2="value with spaces"
|
|
9
|
+
//
|
|
10
|
+
// # ========== Custom Values ==========
|
|
11
|
+
// # user's section; preserved verbatim across setups
|
|
12
|
+
// USER_SECRET="my-secret"
|
|
13
|
+
//
|
|
14
|
+
// Behavior:
|
|
15
|
+
// - The Default section is replaced with the new framework's defaults.
|
|
16
|
+
// - Keys that already had values (in either Default or Custom) keep those values
|
|
17
|
+
// in the same section they were in.
|
|
18
|
+
// - Keys the user added to the Default section that are NOT in the new framework's
|
|
19
|
+
// defaults migrate to the Custom section (so framework cleanups don't lose user data).
|
|
20
|
+
// - .env values are normalized to **double-quoted** form on every merge:
|
|
21
|
+
// KEY=raw-value → KEY="raw-value"
|
|
22
|
+
// KEY="already-quoted" → KEY="already-quoted" (left alone)
|
|
23
|
+
// KEY= → KEY= (empty stays empty/unquoted)
|
|
24
|
+
// This protects values containing spaces, #, $, or other shell-meaningful chars.
|
|
25
|
+
// - .gitignore: same logic, line-based instead of key-based (no quoting).
|
|
26
|
+
// - CLAUDE.md: same logic as .gitignore (line-based, no quoting). The markers
|
|
27
|
+
// render as visible H1 headings in markdown — that's intentional UX.
|
|
28
|
+
// - First setup (no existing file): the framework template lands as-is.
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MARKER = '# ========== Default Values ==========';
|
|
31
|
+
const CUSTOM_MARKER = '# ========== Custom Values ==========';
|
|
32
|
+
|
|
33
|
+
function mergeLineBasedFiles(existingContent, newContent, fileName) {
|
|
34
|
+
const isEnvFile = fileName === '.env';
|
|
35
|
+
|
|
36
|
+
const existingLines = existingContent.split('\n');
|
|
37
|
+
const newLines = newContent.split('\n');
|
|
38
|
+
|
|
39
|
+
// Parse existing into default + custom sections.
|
|
40
|
+
const { defaultLines: existingDefault, customLines: existingCustom, existingDefaultKeys, existingCustomKeys }
|
|
41
|
+
= splitSections(existingLines, isEnvFile);
|
|
42
|
+
|
|
43
|
+
// Parse new content. We only use its default section (custom is the user's domain).
|
|
44
|
+
const { defaultLines: newDefault, customLines: newCustom } = splitSections(newLines, isEnvFile);
|
|
45
|
+
|
|
46
|
+
// Build the merged default section: walk new defaults in order, substituting the
|
|
47
|
+
// user's existing value for any key they had set in either section.
|
|
48
|
+
const newDefaultKeys = new Set();
|
|
49
|
+
const mergedDefault = [];
|
|
50
|
+
const emit = (line) => mergedDefault.push(isEnvFile ? normalizeEnvLine(line) : line);
|
|
51
|
+
|
|
52
|
+
for (const line of newDefault) {
|
|
53
|
+
const trimmed = line.trim();
|
|
54
|
+
|
|
55
|
+
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
|
|
56
|
+
const key = trimmed.split('=')[0].trim();
|
|
57
|
+
if (key) {
|
|
58
|
+
newDefaultKeys.add(key);
|
|
59
|
+
if (existingDefaultKeys.has(key)) {
|
|
60
|
+
emit(findKeyLine(existingDefault, key));
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (existingCustomKeys.has(key)) {
|
|
64
|
+
// Key the user moved to custom — leave it in custom; emit empty default value.
|
|
65
|
+
emit(line);
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
emit(line);
|
|
70
|
+
} else if (!isEnvFile && trimmed && !trimmed.startsWith('#')) {
|
|
71
|
+
// .gitignore / CLAUDE.md: just keep the new line.
|
|
72
|
+
mergedDefault.push(line);
|
|
73
|
+
} else {
|
|
74
|
+
// Comment / blank.
|
|
75
|
+
mergedDefault.push(line);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// User-added stuff in their Default section that the new framework doesn't know about
|
|
80
|
+
// → migrate to Custom so it's preserved without being clobbered next setup.
|
|
81
|
+
const migratedToCustom = [];
|
|
82
|
+
for (const line of existingDefault) {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
85
|
+
|
|
86
|
+
if (isEnvFile) {
|
|
87
|
+
const key = trimmed.split('=')[0].trim();
|
|
88
|
+
if (key && !newDefaultKeys.has(key) && !existingCustomKeys.has(key)) {
|
|
89
|
+
migratedToCustom.push(normalizeEnvLine(line));
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
// .gitignore / CLAUDE.md: line not in new defaults → migrate.
|
|
93
|
+
const inNew = newLines.some((nl) => nl.trim() === trimmed);
|
|
94
|
+
if (!inNew) {
|
|
95
|
+
migratedToCustom.push(line);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// The user's Custom section is preserved verbatim — except .env values get
|
|
101
|
+
// normalized to double-quoted form so the file's quoting style is consistent.
|
|
102
|
+
const finalCustom = isEnvFile
|
|
103
|
+
? existingCustom.map((line) => normalizeEnvLine(line))
|
|
104
|
+
: existingCustom;
|
|
105
|
+
|
|
106
|
+
const result = [];
|
|
107
|
+
result.push(DEFAULT_MARKER);
|
|
108
|
+
result.push(...mergedDefault);
|
|
109
|
+
// Insert a single blank line before CUSTOM_MARKER, but only if the merged default
|
|
110
|
+
// doesn't already end with one (otherwise we'd accumulate an extra blank line on
|
|
111
|
+
// every merge — breaking idempotency on the first re-run after a fresh `jetpack.copy`).
|
|
112
|
+
if (mergedDefault.length === 0 || mergedDefault[mergedDefault.length - 1].trim() !== '') {
|
|
113
|
+
result.push('');
|
|
114
|
+
}
|
|
115
|
+
result.push(CUSTOM_MARKER);
|
|
116
|
+
if (migratedToCustom.length > 0) {
|
|
117
|
+
result.push(...migratedToCustom);
|
|
118
|
+
}
|
|
119
|
+
result.push(...finalCustom);
|
|
120
|
+
|
|
121
|
+
return result.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Normalize a single .env line:
|
|
125
|
+
// - Comments / blanks unchanged
|
|
126
|
+
// - KEY= (empty value) unchanged
|
|
127
|
+
// - KEY="..." (already double-quoted) unchanged
|
|
128
|
+
// - KEY=raw-value → KEY="raw-value" (with embedded " and \ escaped)
|
|
129
|
+
// - KEY='single' → KEY="single" (canonicalize single → double)
|
|
130
|
+
function normalizeEnvLine(line) {
|
|
131
|
+
if (typeof line !== 'string') return line;
|
|
132
|
+
|
|
133
|
+
// Preserve comments and blank lines verbatim.
|
|
134
|
+
const trimmed = line.trim();
|
|
135
|
+
if (!trimmed || trimmed.startsWith('#')) return line;
|
|
136
|
+
|
|
137
|
+
// Capture leading whitespace so we don't lose indentation.
|
|
138
|
+
const leadingMatch = line.match(/^(\s*)/);
|
|
139
|
+
const leading = leadingMatch ? leadingMatch[1] : '';
|
|
140
|
+
|
|
141
|
+
const eqIdx = trimmed.indexOf('=');
|
|
142
|
+
if (eqIdx < 0) return line; // no `=` — not a KEY=VALUE line
|
|
143
|
+
|
|
144
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
145
|
+
let value = trimmed.slice(eqIdx + 1);
|
|
146
|
+
|
|
147
|
+
// Strip trailing whitespace + inline comment-after-value (rare; we only strip a # that follows a space).
|
|
148
|
+
// We do NOT strip # inside quoted values. Detect that by checking if value starts with a quote.
|
|
149
|
+
if (value.length === 0) {
|
|
150
|
+
return `${leading}${key}=`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Already double-quoted? Leave alone (preserves user's exact contents).
|
|
154
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
155
|
+
return `${leading}${key}=${value}`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Single-quoted → canonicalize to double-quoted.
|
|
159
|
+
if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
|
|
160
|
+
const inner = value.slice(1, -1);
|
|
161
|
+
return `${leading}${key}="${escapeForDoubleQuote(inner)}"`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Raw value → wrap.
|
|
165
|
+
return `${leading}${key}="${escapeForDoubleQuote(value)}"`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function escapeForDoubleQuote(s) {
|
|
169
|
+
return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function splitSections(lines, isEnvFile) {
|
|
173
|
+
const defaultLines = [];
|
|
174
|
+
const customLines = [];
|
|
175
|
+
const existingDefaultKeys = new Set();
|
|
176
|
+
const existingCustomKeys = new Set();
|
|
177
|
+
|
|
178
|
+
let mode = null; // null | 'default' | 'custom'
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
const trimmed = line.trim();
|
|
181
|
+
if (trimmed === DEFAULT_MARKER) { mode = 'default'; continue; }
|
|
182
|
+
if (trimmed === CUSTOM_MARKER) { mode = 'custom'; continue; }
|
|
183
|
+
|
|
184
|
+
// Lines before any marker are treated as default (legacy / fresh files).
|
|
185
|
+
if (mode === 'custom') {
|
|
186
|
+
customLines.push(line);
|
|
187
|
+
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
|
|
188
|
+
const key = trimmed.split('=')[0].trim();
|
|
189
|
+
if (key) existingCustomKeys.add(key);
|
|
190
|
+
}
|
|
191
|
+
} else {
|
|
192
|
+
defaultLines.push(line);
|
|
193
|
+
if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
|
|
194
|
+
const key = trimmed.split('=')[0].trim();
|
|
195
|
+
if (key) existingDefaultKeys.add(key);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { defaultLines, customLines, existingDefaultKeys, existingCustomKeys };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function findKeyLine(lines, key) {
|
|
204
|
+
const re = new RegExp(`^\\s*${key}\\s*=`);
|
|
205
|
+
for (const line of lines) {
|
|
206
|
+
if (re.test(line)) return line;
|
|
207
|
+
}
|
|
208
|
+
return `${key}=`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { mergeLineBasedFiles, normalizeEnvLine, DEFAULT_MARKER, CUSTOM_MARKER };
|