@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
@@ -5,17 +5,17 @@
5
5
  * That is correct for a STANDALONE target (the target dir is the git root) and dead on
6
6
  * arrival inside a brand monorepo: GitHub executes workflows from the REPO
7
7
  * ROOT's `.github/workflows/` only, so `targets/extension/.github/workflows/publish.yml`
8
- * never runs — CI builds and store publishes silently do not exist.
8
+ * never runs: CI builds and store publishes silently do not exist.
9
9
  *
10
10
  * In a monorepo the target's workflow is COMPOSED into the root dir instead: one
11
11
  * file per target (`<target>-<workflow>.yml`), every post-checkout `run:` step scoped
12
- * to the target's path, regenerated from the framework template on every setup — so
12
+ * to the target's path, regenerated from the framework template on every setup, so
13
13
  * a re-run updates the target's own file and can never duplicate a job. Each target's
14
14
  * runs also get their own concurrency group, so one target's deploy never cancels
15
15
  * another's.
16
16
  *
17
17
  * Scoping is by working directory, not by a `paths:` trigger filter: OMEGA
18
- * workflows carry NO push triggers by design (deliberate deploys — D13), and a
18
+ * workflows carry NO push triggers by design (deliberate deploys, D13), and a
19
19
  * path filter on a dispatch-only workflow filters nothing. It is declared PER
20
20
  * STEP, not as a workflow-level `defaults.run.working-directory`: that would
21
21
  * also scope the steps that run before actions/checkout (and the jobs that never
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * Composition RECONCILES (#636): the enabled target set derives the composed
25
25
  * file set, so a target the brand's config no longer enables has its composed
26
- * files DELETED (reconcileComposedWorkflows) — the ratified example of a
26
+ * files DELETED (reconcileComposedWorkflows): the ratified example of a
27
27
  * dropped config key becoming a real removal.
28
28
  */
29
29
  const path = require('path');
@@ -68,7 +68,7 @@ const INSTALL_WORKSPACE_FLAG = '--workspace .';
68
68
  * an unscoped artifact upload collects an empty repo-root path.
69
69
  *
70
70
  * Named per action on purpose: a blanket "any input called path" rule would
71
- * rewrite inputs that mean something else — `actions/checkout`'s `path:` names
71
+ * rewrite inputs that mean something else: `actions/checkout`'s `path:` names
72
72
  * where to CLONE the repo, not a file in it. An action missing from this table
73
73
  * is left alone; add it here when a template starts using it.
74
74
  */
@@ -164,7 +164,7 @@ function composeWorkflow(contents, options) {
164
164
  // (and its own engines.node pin) sits beside it.
165
165
  composed = renderInstallWorkspace(composed, { composed: true });
166
166
 
167
- // Display name carries the target — two targets' runs are told apart in the
167
+ // Display name carries the target: two targets' runs are told apart in the
168
168
  // Actions list, where only the workflow name shows.
169
169
  composed = composed.replace(/^name:[ \t]*(.*)$/m, (full, value) => `name: ${value.trim()} (${targetPath})`);
170
170
 
@@ -173,7 +173,7 @@ function composeWorkflow(contents, options) {
173
173
  composed = composed.replace(/^(concurrency:\n(?:[ \t]+.*\n)*?[ \t]+group:[ \t]*)(.*)$/m, (full, prefix, value) => `${prefix}${targetName}-${value.trim()}`);
174
174
 
175
175
  // Every `run:` step that follows its job's checkout executes in the target dir
176
- // (`uses:` actions — checkout and friends — stay at the repo root, which is
176
+ // (`uses:` actions, checkout and friends, stay at the repo root, which is
177
177
  // what they want).
178
178
  composed = scopeRunSteps(composed, targetPath);
179
179
 
@@ -238,12 +238,12 @@ function composeTargetWorkflows(options) {
238
238
  /**
239
239
  * Delete the composed workflows of targets this brand no longer has (#636).
240
240
  * Composing is per-target and a dropped target's framework never runs again, so
241
- * the file it wrote at the brand root would outlive it forever — a workflow
241
+ * the file it wrote at the brand root would outlive it forever: a workflow
242
242
  * Actions still lists and still dispatches for a target that is gone.
243
243
  *
244
244
  * The brand root's `.github/workflows/` is a MIXED dir: the brand's own
245
245
  * human-authored workflows live beside the composed ones. So a file goes only
246
- * past a DOUBLE lock — it carries the GENERATED header (which also NAMES the
246
+ * past a DOUBLE lock: it carries the GENERATED header (which also NAMES the
247
247
  * target it was composed for) AND it is named the composed way,
248
248
  * `<target>-<framework file>.yml`. A human file matches neither and is never
249
249
  * even a candidate; nothing here reads or edits one.
@@ -283,14 +283,14 @@ function reconcileComposedWorkflows(options) {
283
283
  jetpack.remove(file);
284
284
  }
285
285
  result.removed.push(`.github/workflows/${name}`);
286
- logger.log(`Removed .github/workflows/${name} — ${owned.targetPath} is no longer a target of this brand`);
286
+ logger.log(`Removed .github/workflows/${name}: ${owned.targetPath} is no longer a target of this brand`);
287
287
  }
288
288
 
289
289
  return result;
290
290
  }
291
291
 
292
292
  /**
293
- * The workflow file name to dispatch for a target — composed in a brand monorepo,
293
+ * The workflow file name to dispatch for a target: composed in a brand monorepo,
294
294
  * the framework's own name standalone. The ONE place deploy verbs and the
295
295
  * compose step agree on the name.
296
296
  * @param {object} options
@@ -324,7 +324,7 @@ function composedWorkflowNameFor(name, workflow) {
324
324
  // A workflow-level `defaults.run.working-directory` reads cleaner but applies to
325
325
  // EVERY run step, including the ones that execute before actions/checkout (the
326
326
  // git config step, a matrix-resolving step) and the jobs that never check out at
327
- // all — the target dir does not exist there yet, and the job dies on step 1.
327
+ // all: the target dir does not exist there yet, and the job dies on step 1.
328
328
  function scopeRunSteps(contents, targetPath) {
329
329
  const lines = contents.split('\n');
330
330
  const output = [];
@@ -343,7 +343,7 @@ function scopeRunSteps(contents, targetPath) {
343
343
 
344
344
  for (const line of lines) {
345
345
  const indent = line.search(/\S/);
346
- // Comments and blank lines never close a block — they belong to what follows
346
+ // Comments and blank lines never close a block; they belong to what follows
347
347
  const structural = indent >= 0 && !line.trimStart().startsWith('#');
348
348
 
349
349
  // A top-level key closes any open steps list and says whether we are in `jobs:`
@@ -417,8 +417,8 @@ function actionOf(step, keyIndent) {
417
417
  return line ? line.slice(line.indexOf('uses:') + 5).trim().split('@')[0] : null;
418
418
  }
419
419
 
420
- // `hashFiles()` globs from GITHUB_WORKSPACE wherever it appears — no step key
421
- // moves it — so a composed target workflow's patterns carry the target path or match
420
+ // `hashFiles()` globs from GITHUB_WORKSPACE wherever it appears (no step key
421
+ // moves it), so a composed target workflow's patterns carry the target path or match
422
422
  // nothing at all.
423
423
  function scopeHashFiles(step, targetPath) {
424
424
  return step.map((line) => line.replace(/hashFiles\(([^)]*)\)/g, (full, args) => {
@@ -491,7 +491,7 @@ function scopeValue(value, targetPath) {
491
491
  }
492
492
 
493
493
  // A target-relative path/glob. Absolute paths and expression-built values name
494
- // something other than a file in the checkout — those are left as written.
494
+ // something other than a file in the checkout; those are left as written.
495
495
  function joinTargetPath(value, targetPath) {
496
496
  const trimmed = value.trim();
497
497
  const scoped = trimmed.replace(/^\.\//, '');
@@ -503,7 +503,7 @@ function joinTargetPath(value, targetPath) {
503
503
  return `${targetPath}/${scoped}`;
504
504
  }
505
505
 
506
- // Declare `working-directory` on one run step's lines — a step that already
506
+ // Declare `working-directory` on one run step's lines. A step that already
507
507
  // declares its own is left alone.
508
508
  function scopeRunStep(step, keyIndent, targetPath) {
509
509
  const pad = ' '.repeat(keyIndent);
@@ -544,11 +544,11 @@ function sweepTargetCopy(context) {
544
544
  jetpack.remove(targetCopy);
545
545
  pruneEmptyDirs(path.dirname(targetCopy), targetDir);
546
546
  result.removed.push(`.github/workflows/${name}`);
547
- logger.log(`Removed ${targetPath}/.github/workflows/${name} — GitHub only runs workflows from the repo root`);
547
+ logger.log(`Removed ${targetPath}/.github/workflows/${name}: GitHub only runs workflows from the repo root`);
548
548
  return;
549
549
  }
550
550
 
551
- logger.warn(`Kept ${targetPath}/.github/workflows/${name} — it differs from the current framework template (your edits, or an older framework version), and GitHub NEVER runs a workflow from a target dir. Compare it against ${composedName}, move anything it still needs, then delete ${targetPath}/.github/workflows/${name}`);
551
+ logger.warn(`Kept ${targetPath}/.github/workflows/${name}: it differs from the current framework template (your edits, or an older framework version), and GitHub NEVER runs a workflow from a target dir. Compare it against ${composedName}, move anything it still needs, then delete ${targetPath}/.github/workflows/${name}`);
552
552
  }
553
553
 
554
554
  // Is every line of the target's copy a line the current template still ships, in
@@ -559,7 +559,7 @@ function sweepTargetCopy(context) {
559
559
  // same file minus those lines, #334). A line the consumer ADDED or CHANGED is a
560
560
  // line no template of this framework ever shipped, so it fails here and the
561
561
  // copy is kept. Accepted blind spot: an edit that ONLY deletes lines is still a
562
- // subsequence and gets swept — bounded, because a target-dir workflow never runs
562
+ // subsequence and gets swept. Bounded, because a target-dir workflow never runs
563
563
  // and the composed root file is regenerated from the current template.
564
564
  function isFrameworkGeneration(existing, rendered) {
565
565
  const template = rendered.split('\n');
@@ -587,7 +587,7 @@ function pruneEmptyDirs(dir, rootDir) {
587
587
  }
588
588
  }
589
589
 
590
- // posix-style target path — this string ends up inside a YAML workflow.
590
+ // posix-style target path: this string ends up inside a YAML workflow.
591
591
  function relativePath(from, to) {
592
592
  return path.relative(from, to).split(path.sep).join('/');
593
593
  }
@@ -596,14 +596,14 @@ function relativePath(from, to) {
596
596
  // the file is framework-owned AND which target it belongs to, which is what
597
597
  // makes the reconcile able to tell a composed file from a brand's own workflow
598
598
  // without guessing from the name alone.
599
- const generatedMark = (targetPath) => `# GENERATED by the ${targetPath} framework scaffold — do not edit.`;
600
- // Reading accepts the LEGACY wording too (`# GENERATED by \`omega setup\` for
601
- // <targetPath> — do not edit.`): a target dropped before the rewording keeps its
602
- // old-header file forever, which is exactly the file the reconcile exists to
603
- // remove. Composing only ever writes the current sentence.
604
- const GENERATED_MARK = /^# GENERATED by (?:the (\S+) framework scaffold|`omega setup` for (\S+)) — do not edit\.$/;
605
-
606
- // Which target a brand-root workflow file was composed for — null when the file
599
+ const generatedMark = (targetPath) => `# GENERATED by the ${targetPath} framework scaffold. Do not edit.`;
600
+ // Reading also accepts the older forms (the `omega setup` wording, the em dash
601
+ // separator): a dropped target keeps its old-header file forever, which is exactly
602
+ // the file the reconcile exists to remove. Composing only ever writes the current
603
+ // sentence.
604
+ const GENERATED_MARK = /^# GENERATED by (?:the (\S+) framework scaffold|`omega setup` for (\S+))(?:\. Do| \u2014 do) not edit\.$/;
605
+
606
+ // Which target a brand-root workflow file was composed for, null when the file
607
607
  // is not a composed one. BOTH locks must hold: the generation mark on line 1,
608
608
  // and the `<target>-<framework file>.yml` naming the compose writes.
609
609
  function composedWorkflowOwner(name, contents) {
@@ -633,4 +633,4 @@ function header(targetPath) {
633
633
  ].join('\n');
634
634
  }
635
635
 
636
- module.exports = { composeWorkflow, composeTargetWorkflows, composedWorkflowName, composedWorkflowNameFor, reconcileComposedWorkflows, renderInstallFirewall, renderInstallWorkspace, FIREWALL_ACTION, FIREWALL_STEP_ID, FIREWALL_TOKEN, INSTALL_WORKSPACE_TOKEN, INSTALL_WORKSPACE_FLAG };
636
+ module.exports = { composeWorkflow, composeTargetWorkflows, composedWorkflowName, composedWorkflowNameFor, composedWorkflowOwner, reconcileComposedWorkflows, renderInstallFirewall, renderInstallWorkspace, FIREWALL_ACTION, FIREWALL_STEP_ID, FIREWALL_TOKEN, INSTALL_WORKSPACE_TOKEN, INSTALL_WORKSPACE_FLAG };
@@ -27,6 +27,7 @@ const jetpack = require('fs-jetpack');
27
27
  * @param {string} config.commandsDir - Absolute directory of command modules — <name>.js exporting `async (options) => {}`
28
28
  * @param {Object<string, string[]>} [config.aliases] - Command name → positional/flag aliases (e.g. `{ install: ['-i', 'i', '--install'] }`)
29
29
  * @param {string} [config.defaultCommand='help'] - Command used when no positional or flag alias matches
30
+ * @param {Function} [config.fallback] - `(command) => handler|null`: the handler for a command with no file, else null for the unknown answer
30
31
  * @returns {Function} Main class — bins do `new Main(argv)` then `await main.process(argv)`
31
32
  */
32
33
  function createCliRouter(config) {
@@ -35,6 +36,7 @@ function createCliRouter(config) {
35
36
  const commandsDir = config.commandsDir;
36
37
  const aliases = config.aliases || {};
37
38
  const defaultCommand = config.defaultCommand || 'help';
39
+ const fallback = config.fallback || null;
38
40
 
39
41
  if (!commandsDir) {
40
42
  throw new Error('[devkit cli-router] commandsDir is required');
@@ -114,14 +116,18 @@ function createCliRouter(config) {
114
116
 
115
117
  // Get the command file path
116
118
  const commandFile = path.join(commandsDir, `${command}.js`);
119
+ const exists = jetpack.exists(commandFile);
117
120
 
118
- if (!jetpack.exists(commandFile)) {
119
- // Built-in help (a framework's own commands/help.js would have won above)
120
- if (command === 'help') {
121
- printHelp();
122
- return;
123
- }
121
+ // Built-in help (a framework's own commands/help.js would have won above)
122
+ if (!exists && command === 'help') {
123
+ printHelp();
124
+ return;
125
+ }
126
+
127
+ // A router can own verbs it keeps no file for; a null handler is the unknown answer
128
+ const handler = !exists && fallback ? fallback(command) : null;
124
129
 
130
+ if (!exists && !handler) {
125
131
  // Unknown command: name the valid surface and fail loud — the old path
126
132
  // threw a doubled "Error executing… Error: Command…" with no listing.
127
133
  // An empty listing means the dist itself is broken, not a typo — say so.
@@ -137,7 +143,7 @@ function createCliRouter(config) {
137
143
 
138
144
  try {
139
145
  // Execute the command
140
- const Command = require(commandFile);
146
+ const Command = handler || require(commandFile);
141
147
  await Command(options);
142
148
  } catch (e) {
143
149
  // The command's own error is the only surface — no wrapper prefix, no
@@ -1,40 +1,8 @@
1
- // applyDefaults(config) — the shared OMEGA defaults-scaffolding engine.
2
- //
3
- // Every framework ships a defaults tree (src/defaults/ → dist/defaults/) that
4
- // setup copies into the consumer project. This is the single plain-fs engine
5
- // every framework's scaffold runs.
6
- //
7
- // File-map rules: minimatch patterns (dot:true) matched against the RAW path
8
- // relative to defaultsDir, last-match-wins option merging. Per-rule options:
9
- // overwrite bool|fn(item) default true — write even if the destination exists
10
- // skip bool|fn(item) default false — never process this file
11
- // name fn(item) rename the output file
12
- // path fn(item) re-destination the output (relative dir)
13
- // template object render `{{ key.path }}` tokens with this data
14
- // (tolerant: unknown keys survive verbatim, so GitHub
15
- // Actions' `${{ secrets.X }}` passes through)
16
- // merge bool JSON5 defaults-merge with the existing file, written
17
- // through @omega.js/config's comment-preserving editor
18
- // (only the differing keys change; comments stay)
19
- // mergeLines bool OMEGA marker-section line merge (.env/.gitignore/AGENTS.md)
20
- // retire bool brand-context per-target doc retirement: NEVER scaffold
21
- // the file; an existing framework-owned-only copy is
22
- // DELETED (one-time heal — the brand root is the doc
23
- // home), a copy carrying consumer content stays with a
24
- // loud move-it warning (never destroyed)
25
- //
26
- // Engine built-ins (not expressed in the file map):
27
- // - `_.name` segments lose the leading `_` (dotfiles ship past npm's filter)
28
- // - non-final segments starting `_` (but not `_.`) are ARCHIVE dirs — skipped
29
- // (reference material that ships in the framework package, e.g. desktop's `_mas/`)
30
- // - `.gitkeep` creates the destination directory, the file itself never copies
31
- // - `.DS_Store` never copies
32
- // - text writes are skipped when the destination is byte-identical (idempotent
33
- // re-runs report zero writes)
34
- // - a destination carrying the OMEGA section markers when the current template
35
- // no longer does is a PRIOR-GENERATION generated file: framework-owned, so
36
- // `retire` sweeps it and `overwrite: false` still heals it
37
- // - binary files (by extension) copy verbatim — never templated/merged/transformed
1
+ // applyDefaults(config): the ONE plain-fs defaults-scaffolding engine every
2
+ // framework's scaffold runs, copying its defaults tree into the consumer
3
+ // project. The file-map options (overwrite, skip, name, path, template, merge,
4
+ // mergeLines, retire), the built-ins and the minimatch matching rules live in
5
+ // docs/devkit/index.md.
38
6
 
39
7
  const path = require('path');
40
8
  const fs = require('fs');
@@ -264,12 +232,10 @@ function isFrameworkOwned(existing, rendered) {
264
232
  return existing.trim() === rendered.trim();
265
233
  }
266
234
 
267
- // A generation change: the destination carries the OMEGA marker grammar — which
268
- // only this framework's own scaffold writes — while the current template has
269
- // dropped it. That file is a PRIOR GENERATION's generated copy (e.g. the
270
- // content-bearing CLAUDE.md written before it became the one-line `@AGENTS.md`
271
- // pointer), so comparing it against the current render can never match and the
272
- // framework, not the consumer, owns it.
235
+ // A generation change: the destination carries the OMEGA marker grammar (only
236
+ // this framework's scaffold writes it) while the current template dropped it.
237
+ // That file is a PRIOR GENERATION's copy: it can never match the current
238
+ // render, and the framework, not the consumer, owns it.
273
239
  function isLegacyMarkerArtifact(existing, rendered) {
274
240
  return hasSectionMarkers(existing) && !hasSectionMarkers(rendered);
275
241
  }
@@ -59,6 +59,7 @@ const { setTimeout: delay } = require('node:timers/promises');
59
59
  const jetpack = require('fs-jetpack');
60
60
  const { resolveCompany, COMPANY_RESOLVED_FILE } = require('../config/index.js');
61
61
  const { gitAuthEnv, scrubToken } = require('./git-auth.js');
62
+ const { composedWorkflowOwner } = require('./ci-workflows.js');
62
63
 
63
64
  // Constants
64
65
  const API_BASE = 'https://api.github.com';
@@ -342,7 +343,9 @@ async function defaultBranchOf({ owner, repo, token, fetchFn = fetch }) {
342
343
  * this write exists, and it is the only write a deploy ever makes to a default
343
344
  * branch (Ian, 2026-09-14: "if all we are pushing to main is the gh workflow
344
345
  * files that's fine"). Each file's bytes are compared with what the branch
345
- * holds; when every one matches, nothing is written at all.
346
+ * holds; when every one matches, nothing is written at all. A branch file
347
+ * that carries the GENERATED header and is no longer composed (a renamed or
348
+ * dropped target) is RETIRED in the same commit; a hand-written one never is.
346
349
  *
347
350
  * Through the git DATA api, never a working-tree commit: blobs, a tree on top
348
351
  * of the branch's own, a commit whose parent is its head. An EMPTY repo (no
@@ -356,14 +359,14 @@ async function defaultBranchOf({ owner, repo, token, fetchFn = fetch }) {
356
359
  * @param {string} options.token - The GitHub token.
357
360
  * @param {Function} [options.fetchFn] - Injectable fetch (tests).
358
361
  * @param {object} [options.logger] - Logger with `log` (silent when omitted).
359
- * @returns {Promise<{ pushed: string[], sha: string|null }>} What was written.
362
+ * @returns {Promise<{ pushed: string[], removed: string[], sha: string|null }>} What was written.
360
363
  */
361
364
  async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchFn = fetch, logger }) {
362
365
  const files = composedWorkflowFiles(brandRoot);
363
366
 
364
367
  if (files.length === 0) {
365
368
  if (logger) logger.log(`No composed workflow in ${brandRoot}/${WORKFLOWS_DIR}: ${branch} is untouched.`);
366
- return { pushed: [], sha: null };
369
+ return { pushed: [], removed: [], sha: null };
367
370
  }
368
371
 
369
372
  const call = async (route, init) => {
@@ -399,9 +402,31 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
399
402
  }
400
403
  }
401
404
 
402
- if (changed.length === 0) {
405
+ // What the branch holds that this brand no longer composes. The same double
406
+ // lock as the local prune: the GENERATED header and the composed naming, so
407
+ // a hand-written workflow is never a candidate. No listing means no files.
408
+ const composed = new Set(files.map((file) => file.name));
409
+ const listing = await read(
410
+ await call(`/contents/${WORKFLOWS_DIR}?ref=${encodeURIComponent(branch)}`),
411
+ [200, 404],
412
+ `Listing ${WORKFLOWS_DIR}`,
413
+ );
414
+ const retired = [];
415
+
416
+ for (const entry of listing || []) {
417
+ if (entry.type !== 'file' || !entry.name.endsWith('.yml') || composed.has(entry.name)) {
418
+ continue;
419
+ }
420
+
421
+ const held = await read(await call(`/contents/${entry.path}?ref=${encodeURIComponent(branch)}`), [200], `Reading ${entry.name}`);
422
+ if (composedWorkflowOwner(entry.name, Buffer.from(held.content || '', 'base64').toString('utf8'))) {
423
+ retired.push({ name: entry.name, path: entry.path });
424
+ }
425
+ }
426
+
427
+ if (changed.length === 0 && retired.length === 0) {
403
428
  if (logger) logger.log(`${owner}/${repo}#${branch} already carries ${files.map((file) => file.name).join(', ')}: nothing pushed to ${branch}.`);
404
- return { pushed: [], sha: null };
429
+ return { pushed: [], removed: [], sha: null };
405
430
  }
406
431
 
407
432
  // The head this commit sits on. A repo with no commit at all answers 404, and
@@ -422,15 +447,21 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
422
447
  );
423
448
  tree.push({ path: file.path, mode: '100644', type: 'blob', sha: blob.sha });
424
449
  }
450
+ for (const file of retired) {
451
+ tree.push({ path: file.path, mode: '100644', type: 'blob', sha: null });
452
+ }
425
453
 
426
- const names = changed.map((file) => file.name).join(', ');
454
+ const names = [
455
+ ...(changed.length ? [`compose ${changed.map((file) => file.name).join(', ')}`] : []),
456
+ ...(retired.length ? [`retire ${retired.map((file) => file.name).join(', ')}`] : []),
457
+ ].join(', ');
427
458
  const written = await read(
428
459
  await call('/git/trees', { method: 'POST', body: JSON.stringify({ ...(baseTree ? { base_tree: baseTree } : {}), tree }) }),
429
460
  [201],
430
461
  'Writing the tree',
431
462
  );
432
463
  const commit = await read(
433
- await call('/git/commits', { method: 'POST', body: JSON.stringify({ message: `chore(ci): compose ${names}`, tree: written.sha, parents }) }),
464
+ await call('/git/commits', { method: 'POST', body: JSON.stringify({ message: `chore(ci): ${names}`, tree: written.sha, parents }) }),
434
465
  [201],
435
466
  'Writing the commit',
436
467
  );
@@ -444,10 +475,14 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
444
475
  );
445
476
 
446
477
  if (logger) {
447
- logger.log(`Pushed ${changed.map((file) => `${file.name} (${file.reason})`).join(', ')} to ${owner}/${repo}#${branch} as ${shortSha(commit.sha)}: GitHub registers a workflow from the default branch.`);
478
+ const touched = [
479
+ ...changed.map((file) => `${file.name} (${file.reason})`),
480
+ ...retired.map((file) => `${file.name} (retired)`),
481
+ ];
482
+ logger.log(`Pushed ${touched.join(', ')} to ${owner}/${repo}#${branch} as ${shortSha(commit.sha)}: GitHub registers a workflow from the default branch.`);
448
483
  }
449
484
 
450
- return { pushed: changed.map((file) => file.name), sha: commit.sha };
485
+ return { pushed: changed.map((file) => file.name), removed: retired.map((file) => file.name), sha: commit.sha };
451
486
  }
452
487
 
453
488
  /**
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The .env line grammar the marker engine merges by and every in-place key
3
+ * writer sets keys by: logical lines (a quoted value spanning several physical
4
+ * lines is one unit), the key a line assigns, the double-quote normalization,
5
+ * and the replace-or-append of a key's lines. dotenv is the reader every OMEGA loader
6
+ * uses, so it is the judge: a normalized line must read back to the same value,
7
+ * and a key's winning line is its LAST one, as dotenv reads it.
8
+ */
9
+ const dotenv = require('dotenv');
10
+ const { envLine } = require('../config/index.js');
11
+
12
+ // An assignment dotenv accepts: optional indent and `export `, then KEY=
13
+ const ASSIGNMENT = /^(\s*(?:export\s+)?)([\w.-]+)\s*=(.*)$/s;
14
+ const QUOTED_OPEN = /^\s*(?:export\s+)?[\w.-]+\s*=\s*(["'`])/;
15
+
16
+ /**
17
+ * The index of the quote closing a value opened at `from`, as dotenv picks it:
18
+ * a quote after a backslash may close the value or be passed, the first quote
19
+ * after no backslash cannot be passed, and dotenv takes the LAST closing
20
+ * candidate followed on its line by only whitespace or a `# comment`, so
21
+ * `K="x\"` reads `x\`. -1 when none closes it.
22
+ *
23
+ * @param {string} text
24
+ * @param {string} quote - `"`, `'` or a backtick
25
+ * @param {number} from - First index after the opening quote
26
+ * @returns {number}
27
+ */
28
+ function closingQuote(text, quote, from) {
29
+ const lineRest = /[^\S\n]*(?:#[^\n]*)?(?:\n|$)/y;
30
+ let close = -1;
31
+ for (let i = from; i < text.length; i += 1) {
32
+ if (text[i] !== quote) continue;
33
+ lineRest.lastIndex = i + 1;
34
+ if (lineRest.test(text)) close = i;
35
+ if (text[i - 1] !== '\\') break;
36
+ }
37
+ return close;
38
+ }
39
+
40
+ /**
41
+ * Group physical lines into logical ones: a quoted value that closes on a later
42
+ * line joins those lines (newlines kept) into one unit, so it moves and
43
+ * survives whole. An unterminated quote stays line by line, as dotenv reads it.
44
+ *
45
+ * @param {string[]} lines - The file split on `\n`
46
+ * @returns {string[]}
47
+ */
48
+ function joinEnvUnits(lines) {
49
+ const units = [];
50
+ for (let i = 0; i < lines.length; i += 1) {
51
+ const open = lines[i].match(QUOTED_OPEN);
52
+ const rest = open ? lines.slice(i).join('\n') : '';
53
+ const close = open ? closingQuote(rest, open[1], open[0].length) : -1;
54
+ if (close < 0) {
55
+ units.push(lines[i]);
56
+ continue;
57
+ }
58
+
59
+ const end = i + rest.slice(0, close).split('\n').length - 1;
60
+ units.push(lines.slice(i, end + 1).join('\n'));
61
+ i = end;
62
+ }
63
+ return units;
64
+ }
65
+
66
+ /**
67
+ * The key a line assigns, or null for a comment, a blank, or anything else.
68
+ *
69
+ * @param {string} line
70
+ * @returns {string|null}
71
+ */
72
+ function envKey(line) {
73
+ const match = line.match(ASSIGNMENT);
74
+ return match ? match[2] : null;
75
+ }
76
+
77
+ // The inline comment after a raw value: past the closing quote of a quoted
78
+ // value, from the first `#` of an unquoted one.
79
+ function trailingComment(raw) {
80
+ let rest = raw;
81
+ if (raw[0] === "'" || raw[0] === '`') {
82
+ const close = closingQuote(raw, raw[0], 1);
83
+ if (close >= 0) rest = raw.slice(close + 1);
84
+ }
85
+ const hash = rest.indexOf('#');
86
+ return hash < 0 ? '' : rest.slice(hash).trim();
87
+ }
88
+
89
+ /**
90
+ * Normalize one .env line to the double-quoted form: `KEY=raw` and `KEY='x'`
91
+ * become `KEY="..."`, an inline comment kept; an empty value becomes `KEY=""`;
92
+ * a double-quoted value, a comment and a blank are left alone. The rewrite
93
+ * happens only when dotenv reads the same value back; otherwise the line stays.
94
+ *
95
+ * @param {string} line - One logical line
96
+ * @returns {string}
97
+ */
98
+ function normalizeEnvLine(line) {
99
+ const match = line.match(ASSIGNMENT);
100
+ if (!match) return line;
101
+ const [, prefix, key, rawValue] = match;
102
+ const raw = rawValue.trim();
103
+
104
+ if (raw === '') return `${prefix}${key}=""`;
105
+ if (raw.startsWith('"')) return line;
106
+
107
+ const value = dotenv.parse(line)[key];
108
+ if (value === undefined) return line;
109
+
110
+ let quoted;
111
+ try {
112
+ quoted = envLine(key, value);
113
+ } catch (e) {
114
+ // envLine refuses a value dotenv would read back changed: the line stays
115
+ return line;
116
+ }
117
+ const comment = trailingComment(raw);
118
+ const candidate = `${prefix}${quoted}${comment ? ` ${comment}` : ''}`;
119
+ return dotenv.parse(candidate)[key] === value ? candidate : line;
120
+ }
121
+
122
+ /**
123
+ * The line that sets `key`: the LAST one, the value dotenv reads.
124
+ *
125
+ * @param {string[]} lines
126
+ * @param {string} key
127
+ * @returns {string} - `KEY=""` when no line sets it
128
+ */
129
+ function findKeyLine(lines, key) {
130
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
131
+ if (envKey(lines[i]) === key) return lines[i];
132
+ }
133
+ return `${key}=""`;
134
+ }
135
+
136
+ // A placeholder, `# KEY=""` or a bare `# KEY=`, → KEY; any other comment → null.
137
+ function parsePlaceholderKey(trimmed) {
138
+ const match = trimmed.match(/^#\s*([A-Za-z_][A-Za-z0-9_]*)=\s*(?:"")?\s*$/);
139
+ return match ? match[1] : null;
140
+ }
141
+
142
+ /**
143
+ * Set keys in .env content by logical line: the first unit assigning a key, or
144
+ * its `# KEY=""` placeholder, becomes the given line and every later one drops,
145
+ * so dotenv reads the new value; a key the content never had is appended.
146
+ * Every other line keeps its place.
147
+ *
148
+ * @param {string} content - Current file content ('' for none)
149
+ * @param {Object<string, string>} lineFor - KEY to its whole line, e.g. envLine's
150
+ * @returns {string} The new content, ending in exactly one newline
151
+ */
152
+ function setEnvLines(content, lineFor) {
153
+ const body = content.replace(/(?:\r?\n)+$/, '');
154
+ const seen = new Set();
155
+
156
+ const out = joinEnvUnits(body === '' ? [] : body.split(/\r?\n/)).flatMap((unit) => {
157
+ const key = envKey(unit) ?? parsePlaceholderKey(unit.trim());
158
+ if (key === null || !Object.hasOwn(lineFor, key)) return [unit];
159
+ if (seen.has(key)) return [];
160
+ seen.add(key);
161
+ return [lineFor[key]];
162
+ });
163
+ for (const key of Object.keys(lineFor)) {
164
+ if (!seen.has(key)) out.push(lineFor[key]);
165
+ }
166
+ return `${out.join('\n')}\n`;
167
+ }
168
+
169
+ // The framework's own .env comment grammar: a boxed group header or a
170
+ // `# KEY=""` placeholder. Regenerated from the template, never a consumer's note.
171
+ function isMachineComment(trimmed) {
172
+ return /^# ── .* ──$/.test(trimmed) || parsePlaceholderKey(trimmed) !== null;
173
+ }
174
+
175
+ // KEY= / KEY="" / KEY='' (whitespace tolerated) count as empty.
176
+ function envValueIsEmpty(line) {
177
+ const eqIdx = line.indexOf('=');
178
+ if (eqIdx < 0) return true;
179
+ const value = line.slice(eqIdx + 1).trim();
180
+ return value === '' || value === '""' || value === "''";
181
+ }
182
+
183
+ module.exports = { joinEnvUnits, envKey, normalizeEnvLine, findKeyLine, parsePlaceholderKey, isMachineComment, envValueIsEmpty, setEnvLines };