@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,4 +1,4 @@
1
- // The build-mode pipeline shared by the `build`, `package`, and `publish` verbs.
1
+ // The pipeline shared by the `build`, `package`, `publish` and `dev` verbs.
2
2
  //
3
3
  // The CLI verbs are canonical and the synced projectScripts are thin
4
4
  // `omega <verb>` aliases, so a verb must run the pipeline ITSELF and never
@@ -10,8 +10,8 @@
10
10
  // Libraries
11
11
  const { execute } = require('node-powertools');
12
12
 
13
- function gulpCommand(task) {
14
- return `npm run gulp -- ${task}`;
13
+ function gulpCommand(task, args = []) {
14
+ return `npm run gulp -- ${[task, ...args].join(' ')}`;
15
15
  }
16
16
 
17
17
  // Default step implementations — required lazily so requiring a plan never
@@ -30,7 +30,7 @@ const RUNNERS = {
30
30
  'ship-keys': () => require('./ship-keys.js').assertShipKeys(),
31
31
  // `strict` rides the STEP: publish declares it (#891), a bare verb does not.
32
32
  'validate-certs': (options, step) => require('../commands/validate-certs.js')({ ...options, strict: step.strict === true || options.strict === true }),
33
- gulp: (options, step) => execute(gulpCommand(step.task), { log: true }),
33
+ gulp: (options, step) => execute(gulpCommand(step.task, step.args), { log: true }),
34
34
  };
35
35
 
36
36
  async function runPipeline(plan, options, runners) {
@@ -35,6 +35,8 @@
35
35
  const fs = require('fs');
36
36
  const os = require('os');
37
37
  const path = require('path');
38
+ const { envLine, assertEnvReadsBack } = require('../vendor/config/index.js');
39
+ const { setEnvLines } = require('../vendor/devkit/env-lines.js');
38
40
 
39
41
  // Runner files live under %LOCALAPPDATA%\omega-runner — a per-user path that
40
42
  // doesn't need admin to read/write. Set OMEGA_RUNNER_HOME to override.
@@ -308,37 +310,20 @@ function ensureRunnerEnvFile(home) {
308
310
  return true;
309
311
  }
310
312
 
311
- // Write values into the file IN PLACE: an existing `KEY=` line (commented or
312
- // not) is replaced, a key the file never had is appended. Comments and order
313
- // survive, so the file stays the one a person reads.
313
+ // Write values into the file IN PLACE through devkit's setEnvLines: a key's
314
+ // first line or `# KEY=""` placeholder takes the new line and later ones drop,
315
+ // so dotenv reads the new value; a key the file never had is appended. Comments
316
+ // and order survive, so the file stays the one a person reads.
314
317
  function writeRunnerEnvValues(home, values) {
315
- // Every value double-quoted, the one .env quoting rule this repo writes
316
- // everywhere. dotenv reads a double-quoted value back verbatim and has no
317
- // escape for a `"` inside one, so a value carrying one cannot be written at
318
- // all: refuse it — naming the key and the character — before the file is
319
- // touched, rather than save a value that reads back wrong.
320
- const serialize = (key, value) => `${key}="${value}"`;
321
- for (const [key, value] of Object.entries(values)) {
322
- if (String(value).includes('"')) {
323
- throw new Error(`${key} contains a double quote (") — a .env value cannot carry one. Remove it, then set ${key} again.`);
324
- }
325
- }
318
+ // envLine is the one .env serializer; it refuses, naming the key, a value
319
+ // dotenv would read back changed, before the file is touched.
320
+ const lineFor = Object.fromEntries(Object.entries(values).map(([key, value]) => [key, envLine(key, value)]));
326
321
 
327
322
  ensureRunnerEnvFile(home);
328
- const file = runnerEnvFile(home);
329
- const lines = fs.readFileSync(file, 'utf8').split(/\r?\n/);
330
- const seen = new Set();
331
-
332
- const out = lines.map((line) => {
333
- const m = /^\s*#?\s*([A-Z][A-Z0-9_]*)\s*=/.exec(line);
334
- if (!m || !(m[1] in values) || seen.has(m[1])) return line;
335
- seen.add(m[1]);
336
- return serialize(m[1], String(values[m[1]]));
337
- });
338
- for (const [key, value] of Object.entries(values)) {
339
- if (!seen.has(key)) out.push(serialize(key, String(value)));
340
- }
341
- fs.writeFileSync(file, out.join('\n').replace(/\n*$/, '\n'));
323
+ const file = runnerEnvFile(home);
324
+ const content = setEnvLines(fs.readFileSync(file, 'utf8'), lineFor);
325
+ assertEnvReadsBack(content, values);
326
+ fs.writeFileSync(file, content);
342
327
  return file;
343
328
  }
344
329
 
@@ -44,6 +44,9 @@ const fs = require('node:fs');
44
44
  const os = require('node:os');
45
45
  const path = require('node:path');
46
46
  const JSON5 = require('json5');
47
+ const Logger = require('../devkit/logger.js');
48
+
49
+ const logger = new Logger('company');
47
50
 
48
51
  // The company TREE inside the parent brand's repo, and the file an off-laptop
49
52
  // run reads the resolved layer from: GENERATED beside the brand config by the
@@ -62,13 +65,16 @@ const REGISTRY_FILE = 'brands.json';
62
65
  // it visible").
63
66
  const SELF = 'self';
64
67
 
65
- // The legacy stamp #677 retired, warned about where an owner still carries one.
66
- const RETIRED_MARKER = path.join('.omega', 'company.json');
68
+ // The `company` keys the LOADER fills: never typed, always resolved.
69
+ const RESOLVED_COMPANY_KEYS = ['name', 'url', 'images'];
67
70
 
68
71
  // One warning per company id per process: a missing parent is a state a whole
69
72
  // build runs in, never a per-call event.
70
73
  const warned = new Set();
71
74
 
75
+ // One warning per process for a registry file that is there but will not parse.
76
+ let warnedUnreadable = false;
77
+
72
78
  /**
73
79
  * The machine home OMEGA keeps its per-machine state in. `OMEGA_HOME` moves it
74
80
  * (the test lanes point it at a temp dir so a fixture never writes a real line).
@@ -118,6 +124,17 @@ function readRegistry() {
118
124
  return readJson(registryFile()) || {};
119
125
  }
120
126
 
127
+ /**
128
+ * A registry line's root when it is on this machine now, else null: the ONE
129
+ * test both the reader and the write's prune apply, so a line the reader would
130
+ * resolve is never a line the prune drops.
131
+ * @param {object} line - A registry line.
132
+ * @returns {string|null}
133
+ */
134
+ function liveRoot(line) {
135
+ return line && typeof line.root === 'string' && isDir(line.root) ? line.root : null;
136
+ }
137
+
121
138
  /**
122
139
  * Record (or refresh) ONE brand's line in the machine registry, which is what
123
140
  * every `loadConfig()` does for the brand it just loaded: the map of where the
@@ -130,7 +147,13 @@ function readRegistry() {
130
147
  *
131
148
  * A write also PRUNES the lines whose root is gone: the file is a map of what
132
149
  * is on this machine, so a brand that was deleted or moved (and re-recorded
133
- * under its new root) leaves nothing behind to resolve into.
150
+ * under its new root) leaves nothing behind to resolve into. A line whose root
151
+ * is on disk is never pruned (`liveRoot`, the reader's own test).
152
+ *
153
+ * A registry file that is THERE but will not parse is never rewritten: writing
154
+ * over it would keep one line and drop every other brand. The write itself is
155
+ * a rename of a finished temp file, so a racing reader never sees half a file.
156
+ * Every write logs its caller and the kept and pruned ids at debug level.
134
157
  *
135
158
  * @param {object} entry - The brand's own facts.
136
159
  * @param {string} entry.id - `brand.id` (the key).
@@ -143,7 +166,17 @@ function recordBrand({ id, root, name, url }) {
143
166
  if (!id || !root) return false;
144
167
 
145
168
  try {
146
- const registry = readRegistry();
169
+ const file = registryFile();
170
+ const registry = fs.existsSync(file) ? readJson(file) : {};
171
+
172
+ if (!registry) {
173
+ if (!warnedUnreadable) {
174
+ warnedUnreadable = true;
175
+ logger.warn(`Machine registry ${file} does not parse: left as it is, no brand recorded. Delete it and any omega verb rebuilds it.`);
176
+ }
177
+ return false;
178
+ }
179
+
147
180
  const current = registry[id];
148
181
  const line = { root, name: name || null, url: url || null };
149
182
 
@@ -153,10 +186,15 @@ function recordBrand({ id, root, name, url }) {
153
186
 
154
187
  registry[id] = { ...line, updatedAt: new Date().toISOString() };
155
188
 
156
- const live = Object.fromEntries(Object.entries(registry).filter(([, entry]) => entry && isDir(entry.root)));
189
+ const live = Object.fromEntries(Object.entries(registry).filter(([, entry]) => liveRoot(entry)));
190
+ const pruned = Object.keys(registry).filter((key) => !(key in live));
157
191
 
158
192
  fs.mkdirSync(omegaHome(), { recursive: true });
159
- fs.writeFileSync(registryFile(), `${JSON.stringify(live, null, 2)}\n`);
193
+ const temp = `${file}.${process.pid}.tmp`;
194
+ fs.writeFileSync(temp, `${JSON.stringify(live, null, 2)}\n`);
195
+ fs.renameSync(temp, file);
196
+
197
+ logger.debug(`Registry write by ${process.argv[1] || process.argv0} (pid ${process.pid}): kept [${Object.keys(live).join(', ')}], pruned [${pruned.join(', ')}]`);
160
198
  return true;
161
199
  } catch {
162
200
  return false;
@@ -271,13 +309,6 @@ function resolveCompany(brandRoot, brandConfig) {
271
309
  const webhooks = !(config.company && config.company.webhooks === false);
272
310
  const reply = (facts) => answer({ ...facts, webhooks });
273
311
 
274
- // A brand still carrying the retired stamp hears about it once: the folder it
275
- // points at is not read any more, by anything.
276
- if (root && fs.existsSync(path.join(root, RETIRED_MARKER)) && !warned.has(RETIRED_MARKER)) {
277
- warned.add(RETIRED_MARKER);
278
- console.warn(`${path.join(root, RETIRED_MARKER)} is retired (#677): delete it, and name the company with company: { id: '<parent brand.id>' } in omega.json5.`);
279
- }
280
-
281
312
  // No company: the brand IS the whole entity, so the company facts are its own
282
313
  // and every reader works without a fallback.
283
314
  if (!id) {
@@ -290,7 +321,7 @@ function resolveCompany(brandRoot, brandConfig) {
290
321
  }
291
322
 
292
323
  const line = readRegistry()[id];
293
- const parentRoot = line && typeof line.root === 'string' && isDir(line.root) ? line.root : null;
324
+ const parentRoot = liveRoot(line);
294
325
 
295
326
  if (parentRoot) {
296
327
  const parentBrand = (readBrandConfig(parentRoot) || {}).brand || {};
@@ -347,4 +378,5 @@ module.exports = {
347
378
  COMPANY_DIR,
348
379
  COMPANY_RESOLVED_FILE,
349
380
  COMPANY_SELF: SELF,
381
+ RESOLVED_COMPANY_KEYS,
350
382
  };
@@ -27,6 +27,8 @@
27
27
  * brand config is a copy that drifts from the thing it came from.
28
28
  */
29
29
 
30
+ const { isDeepStrictEqual } = require('node:util');
31
+
30
32
  const { SHARED_SCHEMA, TARGET_SCHEMAS } = require('./schema.js');
31
33
  const { isPlainObject } = require('./merge.js');
32
34
 
@@ -59,6 +61,12 @@ function defaultRules(target) {
59
61
  return rules.filter(hasDefault);
60
62
  }
61
63
 
64
+ /**
65
+ * Set a value at a dot-path, creating the objects on the way.
66
+ * @param {object} target
67
+ * @param {string} dotted
68
+ * @param {*} value
69
+ */
62
70
  function setAtPath(target, dotted, value) {
63
71
  const names = dotted.split('.');
64
72
  const leaf = names.pop();
@@ -127,13 +135,26 @@ function defaultComments(target) {
127
135
  return comments;
128
136
  }
129
137
 
130
- function collectMissing(defaults, present, prefix, target, found) {
131
- for (const [key, value] of Object.entries(defaults)) {
138
+ /**
139
+ * What filling `present` from `incoming` does, key by key, never overwriting:
140
+ * a path `present` lacks is ADDED at its highest missing point (one block,
141
+ * not one edit per key inside it), and a path `present` already answers
142
+ * differently is KEPT. An equal value is neither.
143
+ * @param {object} incoming - The values on offer.
144
+ * @param {object} present - The authored config they would fill.
145
+ * @returns {{ added: Array<{ path: string, value: * }>, kept: Array<{ path: string, value: *, incoming: * }> }}
146
+ */
147
+ function planMerge(incoming, present) {
148
+ return collectPlan(incoming, present, '', { added: [], kept: [] });
149
+ }
150
+
151
+ function collectPlan(incoming, present, prefix, plan) {
152
+ for (const [key, value] of Object.entries(incoming)) {
132
153
  const dotted = prefix ? `${prefix}.${key}` : key;
133
154
  const authored = isPlainObject(present) && Object.prototype.hasOwnProperty.call(present, key);
134
155
 
135
156
  if (!authored) {
136
- found.push({ path: dotted, value });
157
+ plan.added.push({ path: dotted, value });
137
158
  continue;
138
159
  }
139
160
 
@@ -141,11 +162,13 @@ function collectMissing(defaults, present, prefix, target, found) {
141
162
  // A brand that authored anything else at this path (a scalar, false, null)
142
163
  // made a decision: never dive in, never overwrite.
143
164
  if (isPlainObject(value) && isPlainObject(present[key])) {
144
- collectMissing(value, present[key], dotted, target, found);
165
+ collectPlan(value, present[key], dotted, plan);
166
+ } else if (!isDeepStrictEqual(value, present[key])) {
167
+ plan.kept.push({ path: dotted, value: present[key], incoming: value });
145
168
  }
146
169
  }
147
170
 
148
- return found;
171
+ return plan;
149
172
  }
150
173
 
151
174
  /**
@@ -167,7 +190,7 @@ function missingDefaults(config, target) {
167
190
  // copy to drift ([#793](https://github.com/Omega-JS-Stack/omega/issues/793))
168
191
  const writable = defaultsFrom(defaultRules(target).filter(isMaterialized));
169
192
 
170
- return collectMissing(writable, config || {}, '', target, []);
193
+ return planMerge(writable, config || {}).added;
171
194
  }
172
195
 
173
- module.exports = { schemaDefaults, missingDefaults, defaultComments, defaultRules };
196
+ module.exports = { schemaDefaults, missingDefaults, defaultComments, defaultRules, planMerge, setAtPath };
@@ -579,12 +579,23 @@ function applyConfigEdits(source, edits, { comments = {} } = {}) {
579
579
  * @param {{ dryRun?: boolean, comments?: Object<string, string> }} [options]
580
580
  * @returns {{ path: string, changed: boolean, applied: string[] }}
581
581
  */
582
- function writeConfigValues(projectDir, edits, { dryRun = false, comments = {} } = {}) {
582
+ function writeConfigValues(projectDir, edits, options) {
583
583
  const configPath = resolveConfigPath(projectDir);
584
584
  if (!configPath) {
585
585
  throw new Error(`No ${FILE_NAME} found under ${projectDir} — cannot write config values`);
586
586
  }
587
587
 
588
+ return writeConfigFileValues(configPath, edits, options);
589
+ }
590
+
591
+ /**
592
+ * writeConfigValues on one named omega file (an overlay, a target's own file).
593
+ * @param {string} configPath - The file to edit.
594
+ * @param {Object<string, *>} edits - Dot-path → value.
595
+ * @param {{ dryRun?: boolean, comments?: Object<string, string> }} [options]
596
+ * @returns {{ path: string, changed: boolean, applied: string[] }}
597
+ */
598
+ function writeConfigFileValues(configPath, edits, { dryRun = false, comments = {} } = {}) {
588
599
  const source = fs.readFileSync(configPath, 'utf8');
589
600
  const applied = pendingEdits(JSON5.parse(source), edits).map(([path]) => path);
590
601
  // Every writeback also normalizes top-level key order (comments travel
@@ -748,12 +759,23 @@ function applyConfigRemovals(source, paths) {
748
759
  * @param {{ dryRun?: boolean }} [options]
749
760
  * @returns {{ path: string, changed: boolean, removed: string[] }}
750
761
  */
751
- function removeConfigValues(projectDir, paths, { dryRun = false } = {}) {
762
+ function removeConfigValues(projectDir, paths, options) {
752
763
  const configPath = resolveConfigPath(projectDir);
753
764
  if (!configPath) {
754
765
  throw new Error(`No ${FILE_NAME} found under ${projectDir} — cannot remove config values`);
755
766
  }
756
767
 
768
+ return removeConfigFileValues(configPath, paths, options);
769
+ }
770
+
771
+ /**
772
+ * removeConfigValues on one named omega file (an overlay, a target's own file).
773
+ * @param {string} configPath - The file to edit.
774
+ * @param {string[]} paths - Dot-paths to delete.
775
+ * @param {{ dryRun?: boolean }} [options]
776
+ * @returns {{ path: string, changed: boolean, removed: string[] }}
777
+ */
778
+ function removeConfigFileValues(configPath, paths, { dryRun = false } = {}) {
757
779
  const source = fs.readFileSync(configPath, 'utf8');
758
780
  const parsed = JSON5.parse(source);
759
781
  const removed = paths.filter((path) => getAtPath(parsed, path) !== undefined);
@@ -766,4 +788,4 @@ function removeConfigValues(projectDir, paths, { dryRun = false } = {}) {
766
788
  return { path: configPath, changed: next !== source, removed };
767
789
  }
768
790
 
769
- module.exports = { applyConfigEdits, writeConfigValues, applyConfigRemovals, removeConfigValues, parseRoot };
791
+ module.exports = { applyConfigEdits, writeConfigValues, writeConfigFileValues, applyConfigRemovals, removeConfigValues, removeConfigFileValues, parseRoot };
@@ -54,7 +54,7 @@ const WORKFLOW_OWNED_KEYS = ['GH_TOKEN', 'CLOUDFLARE_TOKEN', 'NODE_VERSION', 'NO
54
54
 
55
55
  // Rendered in place of the block when a target delivers nothing, so the
56
56
  // generated region is always a valid, self-explaining line of YAML.
57
- const EMPTY_BLOCK = '# (no CI-delivered keys for this target — the next omega verb regenerates this block)';
57
+ const EMPTY_BLOCK = '# (no CI-delivered keys for this target: the next omega verb regenerates this block)';
58
58
 
59
59
  // What a target's generated workflow carries in the runner env, per target.
60
60
  //
@@ -874,12 +874,9 @@ const ENV_SCHEMA = [
874
874
  description: 'Edge Add-ons API key the publish request authenticates with.',
875
875
  },
876
876
 
877
- // No test-lane credentials live here any more
878
- // ([#819](https://github.com/Omega-JS-Stack/omega/issues/819), Ian
879
- // 2026-09-13): web, desktop and extension each test their own sign-in
880
- // against a persona the backend emulator seeds, so a suite never asks a
881
- // brand for a key. The pair that used to sit here is retired outright
882
- // (env-retired.js carries both rows); #904 owns the replacement.
877
+ // No test-lane credentials live here: web, desktop and extension each sign
878
+ // in as a persona the backend emulator seeds, so a suite never asks a brand
879
+ // for a key. The retired pair's rows are @omega.js/manager's migrate table.
883
880
 
884
881
  {
885
882
  name: 'OMEGA_FONTAWESOME_ROOT',
@@ -32,7 +32,6 @@ const path = require('node:path');
32
32
  const { findBrandRoot } = require('./load.js');
33
33
  const { resolveCompany } = require('./company.js');
34
34
  const { ENV_SCHEMA, envFileGroups, envSchemaEntry } = require('./env-schema.js');
35
- const { assertNoRetiredEnvKeys } = require('./env-retired.js');
36
35
  // The one vocabulary lives with the one environment module (#817); this file
37
36
  // re-exports it so the .env overlay names and the runtime answer stay one list.
38
37
  const { ENV_ENVIRONMENTS } = require('./environment.js');
@@ -322,11 +321,8 @@ function reloadEnv(startDir, { target, environment = envEnvironment() } = {}) {
322
321
  /**
323
322
  * Parse one .env file into a plain map. Missing files read as empty.
324
323
  *
325
- * The ONE place a `.env` layer is read, so it is also where a RETIRED key is
326
- * refused ([#893](https://github.com/Omega-JS-Stack/omega/issues/893)): there
327
- * is no dual-read, so a line for a key that moved into config is a value
328
- * nothing consults, and both readers below (the process cascade and the
329
- * artifact composer) fail on it naming the move.
324
+ * The layer is OPEN: an .env may carry keys outside omega, so a key nothing
325
+ * reads (a retired one included) is simply unread. `omega migrate` names those.
330
326
  *
331
327
  * @param {string|null} envPath
332
328
  * @returns {Object<string, string>} Parsed key → value.
@@ -334,10 +330,7 @@ function reloadEnv(startDir, { target, environment = envEnvironment() } = {}) {
334
330
  function parseEnvFile(envPath) {
335
331
  if (!envPath || !fs.existsSync(envPath)) return {};
336
332
 
337
- const parsed = require('dotenv').parse(fs.readFileSync(envPath, 'utf8'));
338
- assertNoRetiredEnvKeys(parsed, envPath);
339
-
340
- return parsed;
333
+ return require('dotenv').parse(fs.readFileSync(envPath, 'utf8'));
341
334
  }
342
335
 
343
336
  /**
@@ -441,34 +434,53 @@ function composeTargetEnv({ targetDir, target, environment = envEnvironment() })
441
434
  }
442
435
 
443
436
  /**
444
- * Serialize one value as a double-quoted .env line — the serializer SSOT every
445
- * writeback rides (this composer, the manager's brand .env writeback and
446
- * scaffold stub). Backslashes, quotes and newlines escape so a multi-line blob
447
- * stays line-safe (dotenv expands `\n` back on read).
437
+ * Throw unless dotenv reads every given value back from `content` exactly as
438
+ * given. The error names the keys, never a value.
439
+ *
440
+ * @param {string} content - .env content holding the written lines.
441
+ * @param {Object<string, string>} values - The values those lines must carry.
442
+ */
443
+ function assertEnvReadsBack(content, values) {
444
+ const parsed = require('dotenv').parse(content);
445
+ const changed = Object.keys(values).filter((key) => parsed[key] !== String(values[key]));
446
+ if (changed.length === 0) return;
447
+
448
+ throw new Error(`.env: the value of ${changed.join(', ')} would not read back unchanged from KEY="value" (dotenv expands a literal \\n or \\r, a quote followed by # ends the value, and a trailing backslash can run into the next line)`);
449
+ }
450
+
451
+ /**
452
+ * Serialize one value as a double-quoted .env line, the serializer SSOT every
453
+ * writeback rides. The value goes in raw (dotenv reads `"`, `\`, `'`, `#` back
454
+ * unchanged); a real newline or carriage return becomes `\n` / `\r`, which
455
+ * dotenv expands back. A value dotenv would read back changed, such as a
456
+ * literal backslash-n or backslash-r, throws naming the key only.
448
457
  *
449
458
  * @param {string} key - Env var name.
450
459
  * @param {string} value - Value to serialize.
451
460
  * @returns {string} `KEY="value"`.
452
461
  */
453
462
  function envLine(key, value) {
454
- const escaped = String(value)
455
- .replace(/\\/g, '\\\\')
456
- .replace(/"/g, '\\"')
457
- .replace(/\n/g, '\\n');
463
+ const raw = String(value).replace(/\n/g, '\\n').replace(/\r/g, '\\r');
464
+ const line = `${key}="${raw}"`;
465
+ assertEnvReadsBack(line, { [key]: value });
458
466
 
459
- return `${key}="${escaped}"`;
467
+ return line;
460
468
  }
461
469
 
462
470
  /**
463
- * Serialize composed values as .env content — one envLine per key.
471
+ * Serialize composed values as .env content, one envLine per key. The whole
472
+ * file is judged too: a value ending in a backslash can carry dotenv's read
473
+ * into the next line, which no single line shows.
464
474
  *
465
475
  * @param {Object<string, string>} values
466
476
  * @returns {string} The file content, newline-terminated.
467
477
  */
468
478
  function serializeEnv(values) {
469
479
  const lines = Object.entries(values).map(([key, value]) => envLine(key, value));
480
+ const content = `${lines.join('\n')}\n`;
481
+ assertEnvReadsBack(content, values);
470
482
 
471
- return `${lines.join('\n')}\n`;
483
+ return content;
472
484
  }
473
485
 
474
- module.exports = { loadEnv, reloadEnv, ENV_ENVIRONMENTS, envEnvironment, resolveEnvChain, envLayerFiles, loadEnvChain, loadEnvRoots, applyDeliverAs, composeTargetEnv, envLine, serializeEnv };
486
+ module.exports = { loadEnv, reloadEnv, ENV_ENVIRONMENTS, envEnvironment, resolveEnvChain, envLayerFiles, loadEnvChain, loadEnvRoots, applyDeliverAs, composeTargetEnv, assertEnvReadsBack, envLine, serializeEnv };
@@ -23,19 +23,17 @@ const { TARGETS, CUSTOM_TARGET_TYPE, isCustomTargetEntry, BACKEND_PROJECT_TYPES,
23
23
  const { clientConfig, CLIENT_FACT_KEYS } = require('./client-config.js');
24
24
  const { deepMerge } = require('./merge.js');
25
25
  const { findSecretKeys, SECRET_KEY_PATTERN } = require('./secrets.js');
26
- const { findRetiredKeys, RETIRED_KEYS, RETIRED_PATHS } = require('./retired-keys.js');
27
26
  const { chosenProvider } = require('./providers.js');
28
- const { validateConfig, runSchema, formatErrors, resolvedBrandHost } = require('./validate.js');
29
- const { loadConfig, composeTargetConfig, hasOmegaConfig, resolveConfigPath, getEnabledTargets, findBrandRoot, findBrandConfigPath, resolveBrandRoot, FILE_NAME, CONFIG_LOCATIONS } = require('./load.js');
27
+ const { validateConfig, undeclaredPaths, undeclaredAuthoredPaths, runSchema, formatErrors, resolvedBrandHost } = require('./validate.js');
28
+ const { loadConfig, composeTargetConfig, hasOmegaConfig, resolveConfigPath, overlayPath, getEnabledTargets, findBrandRoot, findBrandConfigPath, resolveBrandRoot, FILE_NAME, CONFIG_LOCATIONS } = require('./load.js');
30
29
  const { ENV_ENVIRONMENTS, ENVIRONMENT_VAR, getEnvironment, isDevelopment, isProduction, isTesting, setEnvironment, buildLaneEnvironment } = require('./environment.js');
31
- const { loadEnv, reloadEnv, envEnvironment, resolveEnvChain, envLayerFiles, loadEnvChain, loadEnvRoots, applyDeliverAs, composeTargetEnv, envLine, serializeEnv } = require('./env.js');
30
+ const { loadEnv, reloadEnv, envEnvironment, resolveEnvChain, envLayerFiles, loadEnvChain, loadEnvRoots, applyDeliverAs, composeTargetEnv, assertEnvReadsBack, envLine, serializeEnv } = require('./env.js');
32
31
  const { ENV_SCHEMA, ENV_GROUPS, DELIVERY_MODES, envFileGroups, envSchemaEntry, envKeysForTarget, generatedEnvKeys, requiredEnvKeys, envKeysByGroup } = require('./env-schema.js');
33
32
  const { WORKFLOW_OWNED_KEYS, deliveredKeys, workflowSecretKeys, envFileKeys, artifactEnvValues, bakeKeys, bakeSourceKeys, publishSecretKeys, renderSecretsBlock, renderEnvFileKeys } = require('./env-delivery.js');
34
33
  const { checkEnvRules } = require('./env-rules.js');
35
- const { RETIRED_ENV_KEYS, findRetiredEnvKeys, assertNoRetiredEnvKeys } = require('./env-retired.js');
36
34
  const { resolveCompany, recordBrand, readRegistry, registryFile, COMPANY_DIR, COMPANY_RESOLVED_FILE, COMPANY_SELF } = require('./company.js');
37
- const { applyConfigEdits, writeConfigValues, applyConfigRemovals, removeConfigValues } = require('./edit.js');
38
- const { schemaDefaults, missingDefaults, defaultComments } = require('./defaults.js');
35
+ const { applyConfigEdits, writeConfigValues, writeConfigFileValues, applyConfigRemovals, removeConfigValues, removeConfigFileValues } = require('./edit.js');
36
+ const { schemaDefaults, missingDefaults, defaultComments, planMerge, setAtPath } = require('./defaults.js');
39
37
  const { applyCanonicalOrder, CANONICAL_TOP_LEVEL_ORDER } = require('./order.js');
40
38
  const { resolveSeedMode } = require('./seed.js');
41
39
  const { resolveHook, loadHook } = require('./hooks.js');
@@ -55,6 +53,7 @@ module.exports = {
55
53
  composeTargetConfig,
56
54
  hasOmegaConfig,
57
55
  resolveConfigPath,
56
+ overlayPath,
58
57
  getEnabledTargets,
59
58
  findBrandRoot,
60
59
  findBrandConfigPath,
@@ -101,6 +100,7 @@ module.exports = {
101
100
  composeTargetEnv,
102
101
 
103
102
  // The .env serializer SSOT — every writeback renders through these
103
+ assertEnvReadsBack,
104
104
  envLine,
105
105
  serializeEnv,
106
106
 
@@ -136,13 +136,6 @@ module.exports = {
136
136
  // `requiredWhen`; every consumer calls it, none keeps its own if
137
137
  checkEnvRules,
138
138
 
139
- // The .env half of the retired-key register (#893): a key that moved into
140
- // config fails the layer that still declares it, naming the move. Every
141
- // `.env` read goes through parseEnvFile, so nothing can miss the check
142
- RETIRED_ENV_KEYS,
143
- findRetiredEnvKeys,
144
- assertNoRetiredEnvKeys,
145
-
146
139
  // The ONE company resolver (#677): `company: { id }` in, the company's public
147
140
  // facts plus its tree on this machine out. The config chain, the .env chain,
148
141
  // owner hooks and the desktop signing tree all inherit through its `file()`,
@@ -162,8 +155,10 @@ module.exports = {
162
155
  // Writeback (comment-preserving edits + canonical top-level key order)
163
156
  applyConfigEdits,
164
157
  writeConfigValues,
158
+ writeConfigFileValues,
165
159
  applyConfigRemovals,
166
160
  removeConfigValues,
161
+ removeConfigFileValues,
167
162
  applyCanonicalOrder,
168
163
  CANONICAL_TOP_LEVEL_ORDER,
169
164
 
@@ -171,6 +166,8 @@ module.exports = {
171
166
  // heal list the manage walk materializes into a brand's omega.json5
172
167
  schemaDefaults,
173
168
  missingDefaults,
169
+ planMerge,
170
+ setAtPath,
174
171
  defaultComments,
175
172
 
176
173
  // Layer-aware consumer seeding (brand target = no local-layer config at all)
@@ -244,6 +241,8 @@ module.exports = {
244
241
 
245
242
  // Validation
246
243
  validateConfig,
244
+ undeclaredPaths,
245
+ undeclaredAuthoredPaths,
247
246
  runSchema,
248
247
  formatErrors,
249
248
 
@@ -254,9 +253,6 @@ module.exports = {
254
253
 
255
254
  findSecretKeys,
256
255
  SECRET_KEY_PATTERN,
257
- findRetiredKeys,
258
- RETIRED_KEYS,
259
- RETIRED_PATHS,
260
256
 
261
257
  // The one provider shape (#425) — `role.providers.<provider>`, where key
262
258
  // presence is the pick and `false` is the deliberate off switch
@@ -66,7 +66,7 @@ const path = require('node:path');
66
66
  const JSON5 = require('json5');
67
67
 
68
68
  const { deepMerge, isPlainObject } = require('./merge.js');
69
- const { resolveCompany, recordBrand } = require('./company.js');
69
+ const { resolveCompany, recordBrand, RESOLVED_COMPANY_KEYS } = require('./company.js');
70
70
  const { findSecretKeys } = require('./secrets.js');
71
71
  const { validateConfig } = require('./validate.js');
72
72
  const { TARGETS } = require('./schema.js');
@@ -134,10 +134,22 @@ function resolveOverlayEnvironment(environment, fallback) {
134
134
  function resolveOverlayPath(basePath, environment) {
135
135
  if (!basePath || !environment) return null;
136
136
 
137
- const overlay = path.join(path.dirname(basePath), `${path.basename(FILE_NAME, '.json5')}.${environment}.json5`);
137
+ const overlay = overlayPath(basePath, environment);
138
138
  return fs.existsSync(overlay) ? overlay : null;
139
139
  }
140
140
 
141
+ /**
142
+ * The ONE spelling of a layer's environment overlay: `omega.<environment>.json5`
143
+ * beside its base file, whether or not either exists. Every reader and writer
144
+ * of an overlay names it through here.
145
+ * @param {string} basePath - The layer's omega.json5 path.
146
+ * @param {string} environment - One of ENV_ENVIRONMENTS.
147
+ * @returns {string} Absolute overlay path.
148
+ */
149
+ function overlayPath(basePath, environment) {
150
+ return path.join(path.dirname(basePath), `${path.basename(FILE_NAME, '.json5')}.${environment}.json5`);
151
+ }
152
+
141
153
  /**
142
154
  * Cheap probe: does this project have an omega.json5 at all? Frameworks use it
143
155
  * to fail soft in non-consumer dirs (seeded-empty config); tooling uses it as
@@ -301,10 +313,6 @@ function stripTargets(config) {
301
313
  return shared;
302
314
  }
303
315
 
304
- // The `company` keys the LOADER fills (#677): authored, they would be
305
- // overwritten at every load, so an author hears about it at the file.
306
- const RESOLVED_COMPANY_KEYS = ['name', 'url', 'images'];
307
-
308
316
  /**
309
317
  * The company keys nobody types, refused where a human writes them: the BRAND
310
318
  * file and the COMPANY file (#677). The local layer is deliberately exempt,
@@ -737,4 +745,4 @@ function composeTargetConfig(projectDir, target, options) {
737
745
  return { config, files: { local: localPath, brand: brandPath, company: companyPath } };
738
746
  }
739
747
 
740
- module.exports = { loadConfig, composeTargetConfig, hasOmegaConfig, resolveConfigPath, getEnabledTargets, findBrandRoot, findBrandConfigPath, resolveBrandRoot, FILE_NAME, CONFIG_LOCATIONS, TARGET_SUBDIRS };
748
+ module.exports = { loadConfig, composeTargetConfig, hasOmegaConfig, resolveConfigPath, overlayPath, getEnabledTargets, findBrandRoot, findBrandConfigPath, resolveBrandRoot, FILE_NAME, CONFIG_LOCATIONS, TARGET_SUBDIRS };