@omega.js/desktop 0.53.0 → 0.54.1

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 (161) hide show
  1. package/README.md +38 -38
  2. package/dist/cli-run.js +4 -1
  3. package/dist/cli.js +2 -2
  4. package/dist/commands/cdp/client.js +1 -1
  5. package/dist/commands/cdp.js +1 -1
  6. package/dist/commands/clean.js +2 -3
  7. package/dist/commands/dev.js +25 -0
  8. package/dist/commands/lib/ensure-target.js +12 -17
  9. package/dist/commands/lib/migrate.js +17 -0
  10. package/dist/commands/logs.js +1 -1
  11. package/dist/commands/release.js +1 -1
  12. package/dist/commands/test.js +4 -4
  13. package/dist/commands/update.js +5 -4
  14. package/dist/defaults/.github/workflows/build.yml +18 -18
  15. package/dist/defaults/_.gitignore +0 -2
  16. package/dist/defaults/_mas/README.md +3 -3
  17. package/dist/defaults/config/certs/README.md +1 -1
  18. package/dist/defaults/config/omega.json5 +36 -36
  19. package/dist/defaults/docs/README.md +3 -3
  20. package/dist/defaults/gulpfile.js +1 -1
  21. package/dist/defaults/hooks/build/post.js +1 -1
  22. package/dist/defaults/hooks/build/pre.js +1 -1
  23. package/dist/defaults/hooks/notarize/post.js +2 -2
  24. package/dist/defaults/hooks/release/post.js +1 -1
  25. package/dist/defaults/hooks/release/pre.js +1 -1
  26. package/dist/defaults/src/assets/scss/pages/about.scss +1 -1
  27. package/dist/defaults/src/assets/scss/pages/main.scss +1 -1
  28. package/dist/defaults/src/assets/scss/pages/settings.scss +1 -1
  29. package/dist/defaults/src/integrations/context-menu/index.js +11 -11
  30. package/dist/defaults/src/integrations/menu/index.js +5 -5
  31. package/dist/defaults/src/integrations/tray/index.js +9 -9
  32. package/dist/defaults/src/main.js +2 -2
  33. package/dist/defaults/src/preload.js +1 -1
  34. package/dist/defaults/test/README.md +3 -3
  35. package/dist/defaults/test/_init.js +1 -1
  36. package/dist/gulp/tasks/audit.js +5 -8
  37. package/dist/lib/restart-manager/index.js +1 -1
  38. package/dist/lib/restart-manager/install.js +1 -1
  39. package/dist/lib/restart-manager/protocol.js +1 -1
  40. package/dist/main.js +4 -3
  41. package/dist/preload.js +1 -1
  42. package/dist/test/suites/build/audit.test.js +20 -7
  43. package/dist/test/suites/build/build-workflow-jobs.test.js +2 -2
  44. package/dist/test/suites/build/cli.test.js +28 -0
  45. package/dist/test/suites/build/defaults-em-dash.test.js +22 -0
  46. package/dist/test/suites/build/defaults-scaffold.test.js +19 -5
  47. package/dist/test/suites/build/deploy-direct.test.js +7 -5
  48. package/dist/test/suites/build/deploy-dispatch.test.js +2 -1
  49. package/dist/test/suites/build/deploy-hook.test.js +4 -2
  50. package/dist/test/suites/build/dev-verb.test.js +67 -0
  51. package/dist/test/suites/build/ensure-target.test.js +11 -3
  52. package/dist/test/suites/build/merge-line-files.test.js +6 -6
  53. package/dist/test/suites/build/migrate.test.js +29 -0
  54. package/dist/test/suites/build/project-scripts-deps.test.js +6 -10
  55. package/dist/test/suites/build/runner-env-write.test.js +73 -0
  56. package/dist/test/suites/build/runner.test.js +9 -8
  57. package/dist/test/suites/build/setup-scripts.test.js +27 -0
  58. package/dist/test/suites/build/validate-config.test.js +13 -2
  59. package/dist/test/suites/build/verb-logs.test.js +20 -0
  60. package/dist/test/suites/renderer/window-desktop-surface.test.js +1 -1
  61. package/dist/utils/build-pipeline.js +4 -4
  62. package/dist/utils/runner-env.js +13 -28
  63. package/dist/vendor/config/company.js +46 -14
  64. package/dist/vendor/config/defaults.js +30 -7
  65. package/dist/vendor/config/edit.js +25 -3
  66. package/dist/vendor/config/env-delivery.js +1 -1
  67. package/dist/vendor/config/env-schema.js +3 -6
  68. package/dist/vendor/config/env.js +34 -22
  69. package/dist/vendor/config/index.js +13 -17
  70. package/dist/vendor/config/load.js +15 -7
  71. package/dist/vendor/config/repo.js +10 -27
  72. package/dist/vendor/config/schema-client.js +64 -0
  73. package/dist/vendor/config/schema-cloud.js +38 -0
  74. package/dist/vendor/config/schema-manager.js +118 -0
  75. package/dist/vendor/config/schema-overrides.js +68 -0
  76. package/dist/vendor/config/schema.js +99 -152
  77. package/dist/vendor/config/validate.js +97 -77
  78. package/dist/vendor/devkit/agents-md.js +233 -0
  79. package/dist/vendor/devkit/attach-log-file.js +15 -1
  80. package/dist/vendor/devkit/ci-workflows.js +30 -30
  81. package/dist/vendor/devkit/cli-router.js +13 -7
  82. package/dist/vendor/devkit/defaults-engine.js +9 -43
  83. package/dist/vendor/devkit/deploy-snapshot.js +44 -9
  84. package/dist/vendor/devkit/env-lines.js +183 -0
  85. package/dist/vendor/devkit/local.js +62 -10
  86. package/dist/vendor/devkit/lockfile.js +32 -13
  87. package/dist/vendor/devkit/logger.js +7 -2
  88. package/dist/vendor/devkit/merge-line-files.js +219 -176
  89. package/dist/vendor/devkit/omega-bin.js +208 -111
  90. package/dist/vendor/devkit/preludes/docs-sync.js +52 -0
  91. package/dist/vendor/devkit/preludes/index.js +1 -0
  92. package/dist/vendor/devkit/target-picker.js +45 -0
  93. package/dist/vendor/devkit/test/dashed-files.js +37 -0
  94. package/dist/vendor/devkit/test/run-verb-under-tee.js +71 -0
  95. package/dist/vendor/devkit/update.js +15 -15
  96. package/dist/vendor/devkit/verb-scripts.js +40 -0
  97. package/dist/vendor/devkit/verbs.js +170 -0
  98. package/package.json +18 -24
  99. package/dist/commands/install.js +0 -37
  100. package/dist/defaults/AGENTS.md +0 -119
  101. package/dist/defaults/CLAUDE.md +0 -1
  102. package/dist/vendor/config/env-retired.js +0 -137
  103. package/dist/vendor/config/retired-keys.js +0 -635
  104. package/docs/analytics.md +0 -140
  105. package/docs/app-state.md +0 -92
  106. package/docs/audit.md +0 -69
  107. package/docs/auth.md +0 -284
  108. package/docs/auto-updater.md +0 -243
  109. package/docs/boot-sequence.md +0 -44
  110. package/docs/build-system.md +0 -169
  111. package/docs/cdp-debugging.md +0 -169
  112. package/docs/common-mistakes.md +0 -21
  113. package/docs/config-schema.md +0 -120
  114. package/docs/context-menu.md +0 -112
  115. package/docs/context.md +0 -81
  116. package/docs/css.md +0 -84
  117. package/docs/deep-link.md +0 -186
  118. package/docs/environment-detection.md +0 -112
  119. package/docs/fontawesome.md +0 -109
  120. package/docs/hooks.md +0 -89
  121. package/docs/icons.md +0 -79
  122. package/docs/index.md +0 -328
  123. package/docs/installer-options.md +0 -165
  124. package/docs/ipc.md +0 -61
  125. package/docs/lib-modules.md +0 -53
  126. package/docs/logging.md +0 -227
  127. package/docs/menu.md +0 -160
  128. package/docs/releasing.md +0 -239
  129. package/docs/remote-config.md +0 -118
  130. package/docs/remote-scripts.md +0 -144
  131. package/docs/restart-manager.md +0 -144
  132. package/docs/runner.md +0 -290
  133. package/docs/sentry.md +0 -97
  134. package/docs/shared/agent-docs.md +0 -89
  135. package/docs/shared/analytics.md +0 -612
  136. package/docs/shared/brands.md +0 -57
  137. package/docs/shared/breaking-changes.md +0 -917
  138. package/docs/shared/config.md +0 -1948
  139. package/docs/shared/deploys.md +0 -341
  140. package/docs/shared/icons.md +0 -219
  141. package/docs/shared/local-dev.md +0 -167
  142. package/docs/shared/logging.md +0 -205
  143. package/docs/shared/monitoring.md +0 -167
  144. package/docs/shared/publishing.md +0 -187
  145. package/docs/shared/rulings.md +0 -34
  146. package/docs/shared/testing.md +0 -147
  147. package/docs/shared/theming.md +0 -629
  148. package/docs/shared/translation.md +0 -342
  149. package/docs/shared/updates.md +0 -61
  150. package/docs/signing.md +0 -293
  151. package/docs/startup.md +0 -142
  152. package/docs/storage.md +0 -59
  153. package/docs/templating.md +0 -101
  154. package/docs/test-boot-layer.md +0 -157
  155. package/docs/test-framework.md +0 -362
  156. package/docs/themes.md +0 -149
  157. package/docs/tooltips.md +0 -99
  158. package/docs/tray.md +0 -164
  159. package/docs/usage.md +0 -58
  160. package/docs/verts.md +0 -62
  161. package/docs/windows.md +0 -149
@@ -1,72 +1,87 @@
1
- // Merge line-based files (.env, .gitignore, AGENTS.md) during framework setup —
2
- // the OMEGA marker-section protocol: .env double-quote normalization,
3
- // order-safe key substitution, and custom-key promotion (a key the framework
4
- // newly adopts into its Default section is promoted UP from the user's Custom
5
- // section with their value, instead of appearing empty in Default and set in
6
- // Custom).
7
- //
8
- // Convention (the ONE OMEGA marker grammar — `<comment> ========== <Label> ==========`,
9
- // comment token per file type; the rules-file managed block is the `//` flavor of the
10
- // same family, owned by @omega.js/backend — see _attic/plans/archive/marker-harmonization.md):
11
- //
12
- // # ========== Default Values ==========
13
- // # framework-managed; overwritten on every setup
14
- // KEY1=
15
- // KEY2="value with spaces"
16
- //
17
- // # ========== Custom Values ==========
18
- // # user's section; preserved verbatim across setups
19
- // USER_SECRET="my-secret"
20
- //
21
- // Behavior:
22
- // - The Default section is replaced with the new framework's defaults.
23
- // - Keys that already had values (in either Default or Custom) keep those values
24
- // in the same section they were in.
25
- // - The Default section is COMPLETELY managed ([#926](https://github.com/Omega-JS-Stack/omega/issues/926)):
26
- // a line the new framework block no longer carries LEAVES the consumer, so a
27
- // rule the framework retires or rewrites actually takes effect everywhere. A
28
- // retired framework line and a line a user typed inside the framework block
29
- // cannot be told apart, and the block header already says it is overwritten on
30
- // every setup, so `.gitignore` and `AGENTS.md` drop it. Lines a user owns
31
- // belong under the Custom marker, which stays verbatim.
32
- // - .env is the one exception: a retired key holding a NON-EMPTY value migrates
33
- // to the Custom section (that value is data, not a rule), while an empty
34
- // retired key (`KEY=` / `KEY=""`) drops like any other retired line.
35
- // - .env values are normalized to **double-quoted** form on every merge:
36
- // KEY=raw-value → KEY="raw-value"
37
- // KEY="already-quoted" → KEY="already-quoted" (left alone)
38
- // KEY= → KEY= (empty stays empty/unquoted)
39
- // This protects values containing spaces, #, $, or other shell-meaningful chars.
40
- // - .env COMMENTED PLACEHOLDERS (`# KEY=` in the template's Default section)
41
- // mark keys the framework knows but ships no value for (the env cascade
42
- // supplies values from stronger layers — dogfood friction #20). On merge:
43
- // an existing NON-EMPTY value for that key (either section) keeps its line
44
- // in Default; an existing EMPTY value (`KEY=` / `KEY=""`) converges to the
45
- // commented placeholder instead of migrating to Custom.
46
- // - .gitignore: same logic, line-based instead of key-based (no quoting).
47
- // - AGENTS.md: same logic as .gitignore (line-based, no quoting). The markers
48
- // render as visible H1 headings in markdown — that's intentional UX.
49
- // - First setup (no existing file): the framework template lands as-is.
1
+ // Merge line-based files (.env, .gitignore, .gitattributes, AGENTS.md) through
2
+ // the OMEGA marker sections: the framework rewrites Default on every run, the
3
+ // consumer's Custom stays verbatim, and a file with no markers yet converges
4
+ // once, its consumer lines landing under Custom. The protocol (the grammar and its
5
+ // flavors, the .env value rules, placeholders, promotion, the first converge)
6
+ // lives in docs/devkit/index.md.
7
+
8
+ const { joinEnvUnits, envKey, normalizeEnvLine, findKeyLine, parsePlaceholderKey, isMachineComment, envValueIsEmpty } = require('./env-lines');
50
9
 
51
10
  const DEFAULT_MARKER = '# ========== Default Values ==========';
52
11
  const CUSTOM_MARKER = '# ========== Custom Values ==========';
53
12
 
13
+ // The markdown flavor of the one grammar: an HTML comment renders as nothing.
14
+ const MARKDOWN_DEFAULT_MARKER = '<!-- ========== Default Values ========== -->';
15
+ const MARKDOWN_CUSTOM_MARKER = '<!-- ========== Custom Values ========== -->';
16
+
17
+ // Framework text earlier generations wrote, one pattern per line, each block
18
+ // scoped to the file type that shipped it: Custom boilerplate (the retired
19
+ // per-framework AGENTS.md's included), and old .env headers and group notes,
20
+ // which only an unmarked .env's first converge strips.
21
+ const SHIPPED = [
22
+ { file: '.gitignore', lines: [/^# Add your custom ignore patterns below this line$/, /^# \.\.\.$/] },
23
+ { file: '.gitignore', lines: [/^# Add your own ignores below\. This section is preserved across (framework re-syncs|`npx omega setup` runs|`npx mgr setup` runs)\.$/] },
24
+ { file: '.gitignore', lines: [/^# \.\.\.$/] },
25
+ { file: 'AGENTS.md', lines: [/^## Project-specific notes$/, /^Add anything specific to THIS project here\. Edits below this line are preserved across (runs|framework re-syncs|`npx omega setup` runs)\.$/] },
26
+ { file: 'AGENTS.md', lines: [/^<!-- Add your project-specific notes below this line -->$/] },
27
+ {
28
+ file: '.env',
29
+ lines: [
30
+ /^# .+(?: \u2014|:) brand secrets \(gitignored; loaded before every omega(?:-manager)? run\)\.$/,
31
+ /^# Uncomment and fill what this brand uses\. Services without their credentials$/,
32
+ /^# skip cleanly, so add these as the brand adopts each service\.$/,
33
+ ],
34
+ },
35
+ {
36
+ file: '.env',
37
+ lines: [
38
+ /^# .+(?: \u2014|:) COMPANY secrets \(gitignored; loaded UNDER every (?:managed brand's own \.env|brand of this company)\)\.$/,
39
+ /^# Precedence: shell env > brand \.env > this file\. Put here only what every brand$/,
40
+ /^# shares(?: \u2014 anything|\. Anything) brand-specific belongs in that brand's \.env, never here\.$/,
41
+ ],
42
+ },
43
+ {
44
+ file: '.env',
45
+ lines: [
46
+ /^# one per stream; disperse composes each target its own GOOGLE_ANALYTICS_SECRET\), and the$/,
47
+ /^# Auto-generated and persisted here on the first real run \u2014 leave unset:$/,
48
+ /^# ACCOUNT_PASSWORD_SEED, CSC_KEY_PASSWORD$/,
49
+ ],
50
+ },
51
+ ];
52
+
54
53
  function mergeLineBasedFiles(existingContent, newContent, fileName) {
55
54
  const isEnvFile = fileName === '.env';
55
+ const markers = sectionMarkers(fileName);
56
56
 
57
- const existingLines = existingContent.split('\n');
58
- const newLines = newContent.split('\n');
59
-
60
- // Parse existing into default + custom sections.
61
- const { defaultLines: existingDefault, customLines: existingCustom, existingDefaultKeys, existingCustomKeys }
62
- = splitSections(existingLines, isEnvFile);
57
+ const hasMarkers = hasSectionMarkers(existingContent, fileName);
58
+ // .env merges by logical line: a quoted value spanning lines is one unit
59
+ const toLines = (content) => (isEnvFile ? joinEnvUnits(content.split('\n')) : content.split('\n'));
60
+ const existingLines = toLines(existingContent);
61
+ const newLines = toLines(newContent);
63
62
 
64
63
  // Parse new content. We only use its default section (custom is the user's domain).
65
- const { defaultLines: newDefault, customLines: newCustom } = splitSections(newLines, isEnvFile);
64
+ const { defaultLines: newDefault } = splitSections(newLines, markers);
65
+ const newDefaultKeys = new Set(isEnvFile ? newDefault.map((line) => envKey(line) || parsePlaceholderKey(line.trim())).filter(Boolean) : []);
66
+
67
+ let existingDefault = [];
68
+ let existingCustom = [];
69
+ if (hasMarkers) {
70
+ const sections = splitSections(existingLines, markers);
71
+ existingDefault = sections.defaultLines;
72
+ const shipped = !isEnvFile && isShippedBlock(sections.customLines, fileName);
73
+ existingCustom = withPreamble(sections.preamble, shipped ? [''] : sections.customLines);
74
+ } else if (isEnvFile) {
75
+ existingDefault = existingLines.filter((line) => !isMarkerLine(line, markers));
76
+ } else {
77
+ existingCustom = convergeUnmarked(existingLines, newDefault, markers, fileName);
78
+ }
79
+ const keysOf = (lines) => new Set(isEnvFile ? lines.map(envKey).filter(Boolean) : []);
80
+ const existingDefaultKeys = keysOf(existingDefault);
81
+ const existingCustomKeys = keysOf(existingCustom);
66
82
 
67
83
  // Build the merged default section: walk new defaults in order, substituting the
68
84
  // user's existing value for any key they had set in either section.
69
- const newDefaultKeys = new Set();
70
85
  const mergedDefault = [];
71
86
  const emit = (line) => mergedDefault.push(isEnvFile ? normalizeEnvLine(line) : line);
72
87
 
@@ -74,9 +89,8 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
74
89
  const trimmed = line.trim();
75
90
 
76
91
  if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
77
- const key = trimmed.split('=')[0].trim();
92
+ const key = envKey(line);
78
93
  if (key) {
79
- newDefaultKeys.add(key);
80
94
  if (existingDefaultKeys.has(key)) {
81
95
  emit(findKeyLine(existingDefault, key));
82
96
  continue;
@@ -94,13 +108,11 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
94
108
  // .gitignore: just keep the new line.
95
109
  mergedDefault.push(line);
96
110
  } else {
97
- // Comment / blank — for .env, a `# KEY=` placeholder claims the key:
111
+ // Comment or blank. For .env, a `# KEY=""` placeholder claims the key:
98
112
  // an existing set value keeps its line, an empty one converges away.
99
113
  const placeholderKey = isEnvFile ? parsePlaceholderKey(trimmed) : null;
100
114
 
101
115
  if (placeholderKey) {
102
- newDefaultKeys.add(placeholderKey);
103
-
104
116
  const existingLine = existingDefaultKeys.has(placeholderKey)
105
117
  ? findKeyLine(existingDefault, placeholderKey)
106
118
  : existingCustomKeys.has(placeholderKey)
@@ -117,159 +129,175 @@ function mergeLineBasedFiles(existingContent, newContent, fileName) {
117
129
  }
118
130
  }
119
131
 
120
- // Anything in the existing Default section the new framework block no longer
121
- // carries. The block is COMPLETELY managed (#926), so it DROPS: a retired
122
- // framework line and a line the user typed into the framework block cannot be
123
- // told apart, and the block header already says it is overwritten on every
124
- // setup. The one exception is an .env key holding a real value, which is the
125
- // user's DATA rather than a rule, so it migrates down to Custom; an empty one
126
- // drops with the rest. .gitignore and AGENTS.md migrate nothing.
127
- const migratedToCustom = [];
128
- if (isEnvFile) {
129
- for (const line of existingDefault) {
130
- const trimmed = line.trim();
131
- if (!trimmed || trimmed.startsWith('#')) continue;
132
-
133
- const key = trimmed.split('=')[0].trim();
134
- if (key && !newDefaultKeys.has(key) && !existingCustomKeys.has(key) && !envValueIsEmpty(trimmed)) {
135
- migratedToCustom.push(normalizeEnvLine(line));
136
- }
137
- }
138
- }
139
-
140
- // The user's Custom section is preserved — except (a) .env values get normalized
141
- // to double-quoted form for consistent quoting, and (b) any key that was promoted
142
- // UP into the Default section above is dropped here so it isn't duplicated. Keys
143
- // present in BOTH sections are NOT dropped (the Default copy's value won there,
144
- // so removing the Custom copy would silently change dotenv's effective value).
132
+ // A line the new framework block does not carry drops, except an .env key
133
+ // holding a value: the consumer's data, so it moves under Custom. An unmarked
134
+ // .env's first converge also carries every consumer comment there.
135
+ const known = new Set([...newDefaultKeys, ...existingCustomKeys]);
136
+ const migratedToCustom = !isEnvFile
137
+ ? []
138
+ : hasMarkers
139
+ ? existingDefault.filter((line) => isValuedUnknown(line, known)).map(normalizeEnvLine)
140
+ : convergeUnmarkedEnv(existingDefault, newDefault, known);
141
+
142
+ // Custom stays verbatim but for .env quoting and dropping a key promoted UP
143
+ // into Default. A key set in both sections keeps both lines: dotenv reads the
144
+ // last one, the Custom line, so dropping it would change the value.
145
145
  const finalCustom = isEnvFile
146
146
  ? existingCustom
147
147
  .filter((line) => {
148
148
  const trimmed = line.trim();
149
149
  if (!trimmed || trimmed.startsWith('#')) return true;
150
- const key = trimmed.split('=')[0].trim();
150
+ const key = envKey(line);
151
151
  return !(key && newDefaultKeys.has(key) && !existingDefaultKeys.has(key));
152
152
  })
153
153
  .map((line) => normalizeEnvLine(line))
154
154
  : existingCustom;
155
155
 
156
156
  const result = [];
157
- result.push(DEFAULT_MARKER);
157
+ result.push(markers.defaultMarker);
158
158
  result.push(...mergedDefault);
159
159
  // Insert a single blank line before CUSTOM_MARKER, but only if the merged default
160
160
  // doesn't already end with one (otherwise we'd accumulate an extra blank line on
161
- // every merge — breaking idempotency on the first re-run after a fresh `jetpack.copy`).
161
+ // every merge, breaking idempotency on the first re-run after a fresh `jetpack.copy`).
162
162
  if (mergedDefault.length === 0 || mergedDefault[mergedDefault.length - 1].trim() !== '') {
163
163
  result.push('');
164
164
  }
165
- result.push(CUSTOM_MARKER);
165
+ result.push(markers.customMarker);
166
166
  if (migratedToCustom.length > 0) {
167
167
  result.push(...migratedToCustom);
168
168
  }
169
169
  result.push(...finalCustom);
170
+ // A converged file ends on a newline like every marked one
171
+ if (!hasMarkers && result[result.length - 1] !== '') {
172
+ result.push('');
173
+ }
170
174
 
171
175
  return result.join('\n');
172
176
  }
173
177
 
174
- // Normalize a single .env line:
175
- // - Comments / blanks unchanged
176
- // - KEY= (empty value) unchanged
177
- // - KEY="..." (already double-quoted) unchanged
178
- // - KEY=raw-value → KEY="raw-value" (with embedded " and \ escaped)
179
- // - KEY='single' → KEY="single" (canonicalize single → double)
180
- function normalizeEnvLine(line) {
181
- if (typeof line !== 'string') return line;
178
+ // Split a file on its marker lines: the lines above the first marker, the
179
+ // Default section and the Custom section.
180
+ function splitSections(lines, markers) {
181
+ const sections = { preamble: [], defaultLines: [], customLines: [] };
182
+ let into = sections.preamble;
183
+ for (const line of lines) {
184
+ const trimmed = line.trim();
185
+ if (trimmed === markers.defaultMarker) { into = sections.defaultLines; continue; }
186
+ if (trimmed === markers.customMarker) { into = sections.customLines; continue; }
187
+ into.push(line);
188
+ }
189
+ return sections;
190
+ }
182
191
 
183
- // Preserve comments and blank lines verbatim.
192
+ function isMarkerLine(line, markers) {
184
193
  const trimmed = line.trim();
185
- if (!trimmed || trimmed.startsWith('#')) return line;
186
-
187
- // Capture leading whitespace so we don't lose indentation.
188
- const leadingMatch = line.match(/^(\s*)/);
189
- const leading = leadingMatch ? leadingMatch[1] : '';
194
+ return trimmed === markers.defaultMarker || trimmed === markers.customMarker;
195
+ }
190
196
 
191
- const eqIdx = trimmed.indexOf('=');
192
- if (eqIdx < 0) return line; // no `=` — not a KEY=VALUE line
197
+ // Blank lines at either end dropped, a run of blanks inside collapsed to one.
198
+ function tidyParagraphs(lines) {
199
+ const tidy = [];
200
+ for (const line of lines) {
201
+ if (!line.trim() && (tidy.length === 0 || !tidy[tidy.length - 1].trim())) continue;
202
+ tidy.push(line);
203
+ }
204
+ if (tidy.length > 0 && !tidy[tidy.length - 1].trim()) tidy.pop();
205
+ return tidy;
206
+ }
193
207
 
194
- const key = trimmed.slice(0, eqIdx).trim();
195
- const value = trimmed.slice(eqIdx + 1);
208
+ // A marked file's lines above its Default marker open its Custom section.
209
+ function withPreamble(preamble, custom) {
210
+ const lead = tidyParagraphs(preamble);
211
+ if (lead.length === 0) return custom;
212
+ const start = custom.findIndex((line) => line.trim());
213
+ return start < 0 ? [...lead, ''] : [...lead, '', ...custom.slice(start)];
214
+ }
196
215
 
197
- // Strip trailing whitespace + inline comment-after-value (rare; we only strip a # that follows a space).
198
- // We do NOT strip # inside quoted values. Detect that by checking if value starts with a quote.
199
- if (value.length === 0) {
200
- return `${leading}${key}=`;
216
+ // A marker-less file's first converge: paragraph by paragraph, a line the new
217
+ // framework block carries is the framework's, a paragraph whose every entry is
218
+ // one goes whole (its comments were a framework header), and the rest land
219
+ // under Custom in order, one blank line between paragraphs.
220
+ function convergeUnmarked(lines, newDefault, markers, fileName) {
221
+ const framework = new Set(newDefault.map((line) => line.trim()).filter(Boolean));
222
+ // The grammar's comment token opens every marker: `#`, or `<!--` in markdown
223
+ const commentToken = markers.defaultMarker.split(' ')[0];
224
+ const paragraphs = [[]];
225
+ for (const line of lines) {
226
+ const trimmed = line.trim();
227
+ if (isMarkerLine(line, markers)) continue;
228
+ if (trimmed) paragraphs[paragraphs.length - 1].push(line);
229
+ else if (paragraphs[paragraphs.length - 1].length > 0) paragraphs.push([]);
201
230
  }
202
231
 
203
- // Already double-quoted? Leave alone (preserves user's exact contents).
204
- if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
205
- return `${leading}${key}=${value}`;
206
- }
232
+ const custom = [];
233
+ for (const paragraph of paragraphs) {
234
+ const entries = paragraph.filter((line) => !line.trim().startsWith(commentToken));
235
+ if (entries.length > 0 && entries.every((line) => framework.has(line.trim()))) continue;
207
236
 
208
- // Single-quoted → canonicalize to double-quoted.
209
- if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
210
- const inner = value.slice(1, -1);
211
- return `${leading}${key}="${escapeForDoubleQuote(inner)}"`;
237
+ const kept = paragraph.filter((line) => !framework.has(line.trim()) && !isShipped(line, fileName));
238
+ if (kept.length === 0) continue;
239
+ if (custom.length > 0) custom.push('');
240
+ custom.push(...kept);
212
241
  }
213
-
214
- // Raw value → wrap.
215
- return `${leading}${key}="${escapeForDoubleQuote(value)}"`;
242
+ custom.push('');
243
+ return custom;
216
244
  }
217
245
 
218
- function escapeForDoubleQuote(s) {
219
- return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
246
+ // An .env key the framework block does not know, holding a value.
247
+ function isValuedUnknown(line, known) {
248
+ const key = envKey(line);
249
+ return Boolean(key) && !known.has(key) && !envValueIsEmpty(line.trim());
220
250
  }
221
251
 
222
- function splitSections(lines, isEnvFile) {
223
- const defaultLines = [];
224
- const customLines = [];
225
- const existingDefaultKeys = new Set();
226
- const existingCustomKeys = new Set();
227
-
228
- let mode = null; // null | 'default' | 'custom'
252
+ // An unmarked .env's first converge, in file order with its paragraphs: every
253
+ // comment but the framework's own and a run directly above a known key or its
254
+ // placeholder, and every valued key the framework block does not know.
255
+ function convergeUnmarkedEnv(lines, newDefault, known) {
256
+ const framework = new Set(newDefault.map((line) => line.trim()).filter(Boolean));
257
+ const kept = [];
258
+ let run = [];
229
259
  for (const line of lines) {
230
260
  const trimmed = line.trim();
231
- if (trimmed === DEFAULT_MARKER) { mode = 'default'; continue; }
232
- if (trimmed === CUSTOM_MARKER) { mode = 'custom'; continue; }
233
-
234
- // Lines before any marker are treated as default (legacy / fresh files).
235
- if (mode === 'custom') {
236
- customLines.push(line);
237
- if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
238
- const key = trimmed.split('=')[0].trim();
239
- if (key) existingCustomKeys.add(key);
240
- }
241
- } else {
242
- defaultLines.push(line);
243
- if (isEnvFile && trimmed && !trimmed.startsWith('#')) {
244
- const key = trimmed.split('=')[0].trim();
245
- if (key) existingDefaultKeys.add(key);
246
- }
261
+ if (trimmed.startsWith('#') && !isMachineComment(trimmed) && !framework.has(trimmed)) {
262
+ if (!isShipped(line, '.env')) run.push(line);
263
+ continue;
247
264
  }
265
+ const key = envKey(line) || parsePlaceholderKey(trimmed);
266
+ if (!key || !known.has(key)) kept.push(...run);
267
+ if (isValuedUnknown(line, known)) kept.push(normalizeEnvLine(line));
268
+ if (!trimmed) kept.push('');
269
+ run = [];
248
270
  }
249
-
250
- return { defaultLines, customLines, existingDefaultKeys, existingCustomKeys };
271
+ kept.push(...run);
272
+ return tidyParagraphs(kept);
251
273
  }
252
274
 
253
- function findKeyLine(lines, key) {
254
- const re = new RegExp(`^\\s*${key}\\s*=`);
255
- for (const line of lines) {
256
- if (re.test(line)) return line;
257
- }
258
- return `${key}=`;
275
+ /**
276
+ * Is this line framework text an earlier generation shipped in this file type?
277
+ * @param {string} line
278
+ * @param {string} fileName - The file's name (e.g. `.gitignore`, `AGENTS.md`, `.env`)
279
+ * @returns {boolean}
280
+ */
281
+ function isShipped(line, fileName) {
282
+ return SHIPPED.some((entry) => entry.file === fileName && entry.lines.some((shipped) => shipped.test(line.trim())));
259
283
  }
260
284
 
261
- // `# KEY=` (nothing after the =) → KEY; any other comment → null.
262
- function parsePlaceholderKey(trimmed) {
263
- const match = trimmed.match(/^#\s*([A-Za-z_][A-Za-z0-9_]*)=\s*$/);
264
- return match ? match[1] : null;
285
+ // Is this Custom section exactly one block this file type shipped?
286
+ function isShippedBlock(lines, fileName) {
287
+ const body = lines.map((line) => line.trim()).filter(Boolean);
288
+ return SHIPPED.some((entry) => entry.file === fileName && entry.lines.length === body.length && entry.lines.every((shipped, i) => shipped.test(body[i])));
265
289
  }
266
290
 
267
- // KEY= / KEY="" / KEY='' (whitespace tolerated) count as empty.
268
- function envValueIsEmpty(line) {
269
- const eqIdx = line.indexOf('=');
270
- if (eqIdx < 0) return true;
271
- const value = line.slice(eqIdx + 1).trim();
272
- return value === '' || value === '""' || value === "''";
291
+ /**
292
+ * The marker pair a file speaks, by file type: markdown takes the HTML-comment
293
+ * flavor of the one grammar, every other line file the `#` flavor.
294
+ * @param {string} [fileName] - The file's name (e.g. `AGENTS.md`, `.gitignore`)
295
+ * @returns {{ defaultMarker: string, customMarker: string }}
296
+ */
297
+ function sectionMarkers(fileName) {
298
+ return typeof fileName === 'string' && fileName.endsWith('.md')
299
+ ? { defaultMarker: MARKDOWN_DEFAULT_MARKER, customMarker: MARKDOWN_CUSTOM_MARKER }
300
+ : { defaultMarker: DEFAULT_MARKER, customMarker: CUSTOM_MARKER };
273
301
  }
274
302
 
275
303
  /**
@@ -277,26 +305,41 @@ function envValueIsEmpty(line) {
277
305
  * through the merge protocol at least once). Setup validators use this to
278
306
  * distinguish legacy/no-marker files from protocol-managed ones.
279
307
  * @param {string} content
308
+ * @param {string} [fileName] - Selects the marker flavor (default: the `#` flavor)
280
309
  * @returns {boolean}
281
310
  */
282
- function hasSectionMarkers(content) {
283
- return typeof content === 'string'
284
- && content.includes(DEFAULT_MARKER)
285
- && content.includes(CUSTOM_MARKER);
311
+ function hasSectionMarkers(content, fileName) {
312
+ if (typeof content !== 'string') return false;
313
+ const { defaultMarker, customMarker } = sectionMarkers(fileName);
314
+ const lines = content.split('\n').map((line) => line.trim());
315
+ const at = lines.indexOf(defaultMarker);
316
+ return at >= 0 && lines.indexOf(customMarker, at + 1) > at;
286
317
  }
287
318
 
288
319
  /**
289
320
  * Extract the consumer-owned Custom section of a marker file (everything after
290
- * the Custom marker), or '' when there is no marker. The defaults engine's
321
+ * the Custom marker line), or '' when there is no marker. The defaults engine's
291
322
  * `retire` rule uses this to judge whether a per-target doc carries consumer
292
323
  * content or is framework-owned-only.
293
324
  * @param {string} content
325
+ * @param {string} [fileName] - Selects the marker flavor (default: the `#` flavor)
294
326
  * @returns {string}
295
327
  */
296
- function getCustomSection(content) {
328
+ function getCustomSection(content, fileName) {
297
329
  if (typeof content !== 'string') return '';
298
- const idx = content.indexOf(CUSTOM_MARKER);
299
- return idx < 0 ? '' : content.slice(idx + CUSTOM_MARKER.length);
330
+ const { customMarker } = sectionMarkers(fileName);
331
+ const lines = content.split('\n');
332
+ const at = lines.findIndex((line) => line.trim() === customMarker);
333
+ return at < 0 ? '' : ['', ...lines.slice(at + 1)].join('\n');
300
334
  }
301
335
 
302
- module.exports = { mergeLineBasedFiles, normalizeEnvLine, hasSectionMarkers, getCustomSection, DEFAULT_MARKER, CUSTOM_MARKER };
336
+ module.exports = {
337
+ mergeLineBasedFiles,
338
+ normalizeEnvLine,
339
+ hasSectionMarkers,
340
+ getCustomSection,
341
+ sectionMarkers,
342
+ isShipped,
343
+ DEFAULT_MARKER,
344
+ CUSTOM_MARKER,
345
+ };