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.200",
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.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.51.0",
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.0",
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.8.0",
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 };