@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,39 +1,19 @@
1
1
  /**
2
- * Schema-driven validation for resolved omega.json5 configs.
3
- *
4
- * The rule walker (runSchema) carries required/type/match/enum semantics, where match +
5
- * enum (and itemEnum, the same check per member of an array value) only run on
6
- * PRESENT values and a conditional `required` function
7
- * receives the full config. See schema.js for the rule format.
8
- *
9
- * validateConfig() layers on top of the walker:
10
- * - SHARED_SCHEMA always runs; TARGET_SCHEMAS[options.target] adds that
11
- * target's refinements against the same resolved top-level namespace
12
- * - `targets` sanity (#886): every key is a target NAME, so it must be a
13
- * dir-safe slug, and every entry must be an object declaring a `type` the
14
- * framework list knows (an ARRAY is the retired multi-instance form, whose
15
- * replacement is a sibling key; >1 backend target is a WARNING)
16
- * - the `repo` block (#883): one `provider` from REPO_PROVIDERS, and an `org`
17
- * that is a real non-empty string whenever the block is there, because the
18
- * org is the owner of every repo the brand's names derive under
19
- * - `hosting` (#883) is a WEB target's key: a non-web target carrying it says
20
- * something nothing serves, and its provider comes from HOSTING_PROVIDERS
21
- * - `cloud.config.authDomain`, when set, must be the brand's OWN host (the
22
- * resolved target url, else brand.url): a firebaseapp.com value or a
23
- * mismatch is an error, and demo-* (emulator-only) projects are exempt
24
- * - retired keys are always errors (see retired-keys.js) — a name that was
25
- * renamed outright reads as nothing at all, so it fails loudly instead of
26
- * losing its settings silently
27
- * - secret-shaped keys are always errors (see secrets.js) — loadConfig
28
- * additionally hard-fails on them before any merge happens
29
- * - keys the schema does not declare are WARNINGS (#636): the walker only
30
- * ever visits declared paths, so a typo — or a live path nobody declared —
31
- * used to pass in silence
2
+ * Schema-driven validation for resolved omega.json5 configs. runSchema walks
3
+ * the rules (required/type/match/enum/itemEnum, the value checks only on a
4
+ * PRESENT value; schema.js has the rule format). validateConfig() adds the
5
+ * target's refinements, the `targets` sanity (each key a dir-safe NAME, each
6
+ * entry a known `type`), the `repo` block and web-only `hosting`, the brand's
7
+ * own authDomain, the price and feature shapes, and secret-shaped keys. The
8
+ * schema is STRICT: a path no rule declares is an error, one line per path,
9
+ * naming `omega migrate`. The validator knows only the present shape; the
10
+ * migrate verb is the one code that knows an old name.
32
11
  */
33
12
 
34
- const { TARGETS, SHARED_SCHEMA, TARGET_SCHEMAS } = require('./schema.js');
13
+ const { TARGETS, SHARED_SCHEMA, TARGET_SCHEMAS, isCustomTargetEntry } = require('./schema.js');
35
14
  const { findSecretKeys } = require('./secrets.js');
36
- const { findRetiredKeys } = require('./retired-keys.js');
15
+ const { CLIENT_FACT_KEYS } = require('./client-config.js');
16
+ const { RESOLVED_COMPANY_KEYS } = require('./company.js');
37
17
  const { isPlainObject } = require('./merge.js');
38
18
  const { TARGET_NAME_PATTERN, TARGET_TYPES } = require('./targets.js');
39
19
  const { REPO_PROVIDERS, HOSTING_PROVIDERS } = require('./repo.js');
@@ -43,6 +23,14 @@ const { isCountedFeature } = require('../account/features.js');
43
23
  // Firebase's own default authDomain shape: a third-party host by definition
44
24
  const FIREBASE_AUTH_DOMAIN = /\.firebaseapp\.com$/;
45
25
 
26
+ // The company facts the loader fills: a staged compose output carries them on
27
+ // purpose, and the loader refuses a TYPED one in a brand or company file.
28
+ const RESOLVED_PATHS = RESOLVED_COMPANY_KEYS.map((key) => `company.${key}`);
29
+
30
+ // The build facts a surface bakes AFTER the load: declared only for
31
+ // `decorated: true`, the re-validation of a baked config (a desktop boot).
32
+ const BUILD_FACT_PATHS = CLIENT_FACT_KEYS;
33
+
46
34
  /**
47
35
  * Read a dotted path out of a config object — the path resolver the schema
48
36
  * walk and the env presence checker (env-rules.js) share.
@@ -419,24 +407,23 @@ function validateFeatures(config) {
419
407
  }
420
408
 
421
409
  /**
422
- * The LEAF paths of a resolved config the schema does not declare (#636).
423
- *
424
- * A rule declares its own path AND everything beneath it — an `object`/`array`
425
- * rule is a declared subtree (brand.address's postal fields, an open provider
426
- * map), which is what keeps a brand's own data out of this list. An empty
427
- * object/array is itself a leaf, declared when the schema declares anything
428
- * below it (`certificates: {}` against certificates.enabled).
429
- *
430
- * The `targets` subtree is skipped whole: those keys belong to a framework or,
431
- * for a custom target (#603), to the brand — the targets-sanity check above is
432
- * what polices that namespace.
433
- *
434
- * @param {object} config - The resolved config object.
410
+ * The LEAF paths of a config the schema does not declare. A rule declares its
411
+ * own path; an `object`/`array` rule opens its whole subtree only when no rule
412
+ * is declared beneath it (or it says `open: true`), so a section with typed
413
+ * keys stays closed. An empty object/array (or a `false` off switch) is a leaf,
414
+ * declared when the schema declares anything below it. `targets` is skipped
415
+ * whole: each target's own load judges its entry.
416
+ * @param {object} config - The config object.
435
417
  * @param {object[]} schema - Rule array in the schema.js entry format.
418
+ * @param {string[]} [written] - Paths framework code wrote onto a decorated config, each open.
436
419
  * @returns {string[]} Undeclared leaf paths, in config order.
437
420
  */
438
- function findUndeclaredPaths(config, schema) {
439
- const declared = schema.map((rule) => rule.path);
421
+ function findUndeclaredPaths(config, schema, written = []) {
422
+ const paths = [...schema.map((rule) => rule.path), ...written];
423
+ const open = schema
424
+ .filter((rule) => /\b(object|array)\b/.test(rule.type || '') && (rule.open || !paths.some((other) => other.startsWith(`${rule.path}.`))))
425
+ .map((rule) => rule.path)
426
+ .concat(written);
440
427
  const found = [];
441
428
 
442
429
  const walk = (value, path) => {
@@ -444,9 +431,10 @@ function findUndeclaredPaths(config, schema) {
444
431
  Object.keys(value).forEach((key) => walk(value[key], `${path}.${key}`));
445
432
  return;
446
433
  }
447
- if (!declared.some((rule) => rule === path || path.startsWith(`${rule}.`) || rule.startsWith(`${path}.`))) {
448
- found.push(path);
449
- }
434
+ const declared = paths.includes(path)
435
+ || open.some((rule) => path.startsWith(`${rule}.`))
436
+ || paths.some((rule) => rule.startsWith(`${path}.`));
437
+ if (!declared) found.push(path);
450
438
  };
451
439
 
452
440
  Object.keys(isPlainObject(config) ? config : {})
@@ -456,25 +444,69 @@ function findUndeclaredPaths(config, schema) {
456
444
  return found;
457
445
  }
458
446
 
447
+ /**
448
+ * The rules a target's resolved config answers to: shared + its refinements.
449
+ * @param {string} [target] - Canonical target name; omitted = shared only.
450
+ * @returns {object[]}
451
+ */
452
+ function schemaFor(target) {
453
+ if (target && !TARGETS.includes(target)) {
454
+ throw new Error(`Unknown target "${target}": must be one of [${TARGETS.join(', ')}]`);
455
+ }
456
+
457
+ return target ? [...SHARED_SCHEMA, ...TARGET_SCHEMAS[target]] : SHARED_SCHEMA;
458
+ }
459
+
460
+ /**
461
+ * The paths a config carries that no rule declares, for one target's view:
462
+ * what the strict check fails, for a caller (a converter) that drops them.
463
+ * @param {object} config - A resolved config (a target's keys at the top level).
464
+ * @param {object} [options]
465
+ * @param {string} [options.target]
466
+ * @returns {string[]} Undeclared leaf paths, in config order.
467
+ */
468
+ function undeclaredPaths(config, options) {
469
+ return findUndeclaredPaths(config, schemaFor((options || {}).target), RESOLVED_PATHS);
470
+ }
471
+
472
+ /**
473
+ * Every path of an AUTHORED config (one omega.json5 as written) the strict
474
+ * schema refuses: the whole file, `targets` left to its targets, then each
475
+ * framework target entry the way its own load sees it (hoisted to the top
476
+ * level). A target's OWN file names its type, since its top level is that
477
+ * target's layer. A custom target's keys are its own; an unknown type is the
478
+ * targets check's to report.
479
+ * @param {object} config - One file's parsed config.
480
+ * @param {object} [options]
481
+ * @param {string} [options.target] - The type whose own file this is.
482
+ * @returns {string[]} Dotted paths as the file spells them.
483
+ */
484
+ function undeclaredAuthoredPaths(config, options) {
485
+ const found = undeclaredPaths(config, { target: (options || {}).target });
486
+ const targets = isPlainObject(config) && isPlainObject(config.targets) ? config.targets : {};
487
+
488
+ for (const [name, entry] of Object.entries(targets)) {
489
+ if (!isPlainObject(entry) || isCustomTargetEntry(entry) || !TARGETS.includes(entry.type)) continue;
490
+ found.push(...undeclaredPaths(entry, { target: entry.type }).map((dotted) => `targets.${name}.${dotted}`));
491
+ }
492
+
493
+ return found;
494
+ }
495
+
459
496
  /**
460
497
  * Validate a resolved config: shared schema + optional target refinements +
461
498
  * targets-key sanity + secret-shaped-key detection.
462
499
  * @param {object} config - The resolved config object.
463
500
  * @param {object} [options]
464
501
  * @param {string} [options.target] - Canonical target name; adds TARGET_SCHEMAS[target].
502
+ * @param {boolean} [options.decorated] - The config already carries a build's
503
+ * facts (a baked config, validated again at boot).
465
504
  * @returns {{ errors: string[], warnings: string[] }}
466
505
  */
467
506
  function validateConfig(config, options) {
468
507
  options = options || {};
469
508
 
470
- if (options.target && !TARGETS.includes(options.target)) {
471
- throw new Error(`Unknown target "${options.target}" — must be one of [${TARGETS.join(', ')}]`);
472
- }
473
-
474
- const schema = options.target
475
- ? [...SHARED_SCHEMA, ...TARGET_SCHEMAS[options.target]]
476
- : SHARED_SCHEMA;
477
-
509
+ const schema = schemaFor(options.target);
478
510
  const errors = runSchema(config, schema);
479
511
  const warnings = [];
480
512
 
@@ -573,25 +605,13 @@ function validateConfig(config, options) {
573
605
  // ─── the features catalog and the values products name (#647) ──────────
574
606
  validateFeatures(config).forEach((error) => errors.push(error));
575
607
 
576
- // ─── undeclared paths (#636) ───────────────────────────────────────────
577
- // A warning, never an error: a brand config outliving one framework version
578
- // must still build, and the finding is what closes the gap — either the key
579
- // is dead, or the schema owes it a rule.
580
- const undeclared = findUndeclaredPaths(config, schema);
581
- if (undeclared.length > 0) {
582
- warnings.push(
583
- 'config carries keys the schema does not declare — nothing reads them, so a typo looks exactly like a '
584
- + `feature. Remove them, or give each one a rule in @omega.js/config's schema.js `
585
- + `(docs/shared/config.md → Validation): ${undeclared.join(', ')}`,
586
- );
587
- }
588
-
589
- // ─── retired keys (#142) ───────────────────────────────────────────────
590
- findRetiredKeys(config).forEach(({ path, replacement, why }) => {
608
+ // ─── the schema is strict ──────────────────────────────────────────────
609
+ // A key nothing declares is a key nothing reads: a typo or a legacy shape.
610
+ // One line per path, so a brand sees every key it has to move or delete.
611
+ findUndeclaredPaths(config, schema, options.decorated ? [...RESOLVED_PATHS, ...BUILD_FACT_PATHS] : RESOLVED_PATHS).forEach((path) => {
591
612
  errors.push(
592
- `config.${path} is retired — the key is now "${replacement}" (${why}). `
593
- + `Rename it; there is no dual-read, so the old name is silently ignored `
594
- + `(docs/shared/config.md → "Migration — legacy configs")`,
613
+ `config.${path} is not a key the schema declares. Remove it, or if it is a legacy key run `
614
+ + '`npx omega migrate` at the brand root (report) and `--execute` to convert (docs/shared/config.md → Validation)',
595
615
  );
596
616
  });
597
617
 
@@ -610,4 +630,4 @@ function formatErrors(errors) {
610
630
  return errors.map((e, i) => ` ${i + 1}. ${e}`).join('\n');
611
631
  }
612
632
 
613
- module.exports = { validateConfig, runSchema, formatErrors, getPath, resolvedBrandHost };
633
+ module.exports = { validateConfig, undeclaredPaths, undeclaredAuthoredPaths, runSchema, formatErrors, getPath, resolvedBrandHost };
@@ -0,0 +1,233 @@
1
+ /**
2
+ * The ONE builder of a project-root AGENTS.md, a brand root and a framework
3
+ * used alone alike, written through the one marker engine: the Default section
4
+ * imports the installed manager's AGENTS.md (which imports the omega map from
5
+ * the docs the manager ships), the Custom section holds the project's own
6
+ * notes, verbatim. Every older shape converges once, its notes kept under
7
+ * Custom. A brand target carries none: its brand root is the doc home.
8
+ */
9
+ const { join } = require('node:path');
10
+ const fs = require('node:fs');
11
+ const jetpack = require('fs-jetpack');
12
+ const { mergeLineBasedFiles, hasSectionMarkers, getCustomSection, isShipped, sectionMarkers, DEFAULT_MARKER } = require('./merge-line-files');
13
+
14
+ const FILE_NAME = 'AGENTS.md';
15
+ const GUIDE_SUBPATH = 'node_modules/@omega.js/manager/AGENTS.md';
16
+ const IMPORT_LINE = `@${GUIDE_SUBPATH}`;
17
+ // The retired scope-level link: an import of it is healed away and the link
18
+ // itself removed.
19
+ const RETIRED_SUBPATH = 'node_modules/@omega.js/AGENTS.md';
20
+
21
+ const SCOPE_PREFIXES = ['', '../', '../../', '../../../'];
22
+
23
+ // The skeleton heading earlier builders wrote under the import.
24
+ const NOTES_HEADING = /^# .+: (project|brand) notes$/;
25
+
26
+ /**
27
+ * Is this line an import of the manager's AGENTS.md or the retired scope link
28
+ * (any relative depth)?
29
+ *
30
+ * @param {string} line - A single file line
31
+ * @returns {boolean}
32
+ */
33
+ function isImportLine(line) {
34
+ const trimmed = line.trim();
35
+ return trimmed.startsWith('@') && (trimmed.endsWith(GUIDE_SUBPATH) || trimmed.endsWith(RETIRED_SUBPATH));
36
+ }
37
+
38
+ /**
39
+ * The @omega.js scope directory serving this project: its own node_modules
40
+ * normally, an ancestor's when hoisted (npm workspaces). Only a scope holding
41
+ * the manager counts: an empty dir from a partial install would leave the
42
+ * import dangling while the real scope sits a level up.
43
+ *
44
+ * @param {string} root - Absolute project root
45
+ * @returns {{prefix: string, scopeDir: string}|null}
46
+ */
47
+ function findScope(root) {
48
+ for (const prefix of SCOPE_PREFIXES) {
49
+ const scopeDir = join(root, prefix, 'node_modules', '@omega.js');
50
+ if (jetpack.exists(join(scopeDir, 'manager')) === 'dir') {
51
+ return { prefix, scopeDir };
52
+ }
53
+ }
54
+ return null;
55
+ }
56
+
57
+ /**
58
+ * Resolve the import line for THIS project: the depth that reaches the scope
59
+ * holding the manager. Falls back to the canonical project-local path when
60
+ * the manager is not installed, so the line still names where the docs live.
61
+ *
62
+ * @param {string} root - Absolute project root
63
+ * @returns {string} - The `@<relative path>` import line
64
+ */
65
+ function resolveImportLine(root) {
66
+ const scope = findScope(root);
67
+ return scope ? `@${scope.prefix}${GUIDE_SUBPATH}` : IMPORT_LINE;
68
+ }
69
+
70
+ /**
71
+ * Remove the retired `node_modules/@omega.js/AGENTS.md` link (or a stale file
72
+ * in its place) from the scope serving this brand.
73
+ *
74
+ * @param {string} brandRoot - Absolute brand monorepo root
75
+ * @param {{ dryRun?: boolean }} [options] - dryRun: the same verdict, nothing removed
76
+ * @returns {'removed'|'absent'} - What happened
77
+ */
78
+ function removeRetiredLink(brandRoot, { dryRun = false } = {}) {
79
+ const scope = findScope(brandRoot);
80
+ const link = scope && join(scope.scopeDir, 'AGENTS.md');
81
+ if (!link || !fs.lstatSync(link, { throwIfNoEntry: false })) {
82
+ return 'absent';
83
+ }
84
+ if (!dryRun) {
85
+ fs.rmSync(link, { force: true });
86
+ }
87
+ return 'removed';
88
+ }
89
+
90
+ /**
91
+ * Render a fresh project-root AGENTS.md: the import under the Default marker,
92
+ * then the Custom marker and nothing below it.
93
+ *
94
+ * @param {string} [importLine] - The resolved import line
95
+ * @returns {string} - Full file content
96
+ */
97
+ function renderAgentsMd(importLine = IMPORT_LINE) {
98
+ const { defaultMarker, customMarker } = sectionMarkers(FILE_NAME);
99
+ return `${defaultMarker}\n${importLine}\n\n${customMarker}\n`;
100
+ }
101
+
102
+ /**
103
+ * Is this the retired per-framework template? It OPENS with the `#` Default
104
+ * marker; a file that merely quotes the markers in its notes is not one.
105
+ *
106
+ * @param {string} content - The AGENTS.md content
107
+ * @returns {boolean}
108
+ */
109
+ function isRetiredTemplate(content) {
110
+ return content.split('\n')[0].trim() === DEFAULT_MARKER && hasSectionMarkers(content);
111
+ }
112
+
113
+ /**
114
+ * The notes a consumer wrote in an AGENTS.md of any shape, trimmed: the Custom
115
+ * section of a marked or retired-template file, else the whole file, minus
116
+ * every line a framework wrote (the import, the skeleton heading, shipped
117
+ * boilerplate).
118
+ *
119
+ * @param {string} content - The AGENTS.md content
120
+ * @returns {string} - '' when the file holds nothing the framework did not write
121
+ */
122
+ function consumerNotes(content) {
123
+ const source = hasSectionMarkers(content, FILE_NAME)
124
+ ? getCustomSection(content, FILE_NAME)
125
+ : isRetiredTemplate(content) ? getCustomSection(content) : content;
126
+ return source.split('\n')
127
+ .filter((line) => !isImportLine(line) && !NOTES_HEADING.test(line.trim()) && !isShipped(line, FILE_NAME))
128
+ .join('\n')
129
+ .trim();
130
+ }
131
+
132
+ /**
133
+ * Ensure the project-root AGENTS.md is the marked shape with the resolved
134
+ * import. A marked file goes through the engine (the Default section healed,
135
+ * the Custom section verbatim); any other shape converges, its notes landing
136
+ * under Custom in order.
137
+ *
138
+ * @param {string} root - Absolute project root
139
+ * @param {{ dryRun?: boolean }} [options] - dryRun: the same verdict, nothing written
140
+ * @returns {'present'|'created'|'healed'|'converged'} - What happened
141
+ */
142
+ function ensureAgentsMd(root, { dryRun = false } = {}) {
143
+ const file = join(root, FILE_NAME);
144
+ const template = renderAgentsMd(resolveImportLine(root));
145
+ const existing = jetpack.read(file);
146
+
147
+ if (existing === undefined) {
148
+ if (!dryRun) jetpack.write(file, template);
149
+ return 'created';
150
+ }
151
+
152
+ const marked = hasSectionMarkers(existing, FILE_NAME);
153
+ const next = mergeLineBasedFiles(marked ? existing : consumerNotes(existing), template, FILE_NAME);
154
+ if (next === existing) {
155
+ return 'present';
156
+ }
157
+ if (!dryRun) jetpack.write(file, next);
158
+ return marked ? 'healed' : 'converged';
159
+ }
160
+
161
+ /**
162
+ * Keep a brand target free of any AGENTS.md: the brand root is its doc home. A
163
+ * copy holding nothing the consumer wrote is removed; one with notes stays.
164
+ *
165
+ * @param {string} targetDir - Absolute brand target root
166
+ * @returns {'absent'|'removed'|'kept'} - What happened
167
+ */
168
+ function retireAgentsMd(targetDir) {
169
+ const file = join(targetDir, 'AGENTS.md');
170
+ const existing = jetpack.read(file);
171
+
172
+ if (existing === undefined) {
173
+ return 'absent';
174
+ }
175
+ if (consumerNotes(existing)) {
176
+ return 'kept';
177
+ }
178
+ jetpack.remove(file);
179
+ return 'removed';
180
+ }
181
+
182
+ /**
183
+ * A framework scaffold's AGENTS.md step, recorded into the defaults engine's
184
+ * result and logged the way the engine logs: a standalone project root gets
185
+ * the builder's file; a brand target gets none.
186
+ *
187
+ * @param {object} options
188
+ * @param {string} options.outputDir - Absolute project or target root
189
+ * @param {boolean} options.standalone - True when no brand root sits above it
190
+ * @param {{written: string[], merged: string[], skipped: string[], removed: string[]}} options.result - The engine result to record into
191
+ * @param {object} [options.logger] - `{ log, warn }` (defaults to console)
192
+ */
193
+ function scaffoldAgentsMd({ outputDir, standalone, result, logger = console }) {
194
+ if (!standalone) {
195
+ const verdict = retireAgentsMd(outputDir);
196
+ if (verdict === 'removed') {
197
+ result.removed.push('AGENTS.md');
198
+ logger.warn('Retired AGENTS.md: a brand target carries none, the brand root AGENTS.md is the one doc home');
199
+ } else if (verdict === 'kept') {
200
+ result.skipped.push('AGENTS.md');
201
+ logger.warn('Kept AGENTS.md: it carries consumer content. A brand target carries no AGENTS.md: move those notes under the Custom marker of the brand root AGENTS.md, then delete the file');
202
+ }
203
+ return;
204
+ }
205
+
206
+ const verdict = ensureAgentsMd(outputDir);
207
+ if (verdict === 'created') {
208
+ result.written.push('AGENTS.md');
209
+ logger.log('Scaffolded → AGENTS.md');
210
+ } else if (verdict === 'healed') {
211
+ result.merged.push('AGENTS.md');
212
+ logger.log('Healed → AGENTS.md: the Default section imports the manager, your Custom section kept');
213
+ } else if (verdict === 'converged') {
214
+ result.merged.push('AGENTS.md');
215
+ logger.warn('Converged AGENTS.md to the marker sections: the manager import under Default, your notes under Custom');
216
+ } else {
217
+ result.skipped.push('AGENTS.md');
218
+ }
219
+ }
220
+
221
+ module.exports = {
222
+ GUIDE_SUBPATH,
223
+ IMPORT_LINE,
224
+ RETIRED_SUBPATH,
225
+ isImportLine,
226
+ findScope,
227
+ resolveImportLine,
228
+ removeRetiredLink,
229
+ renderAgentsMd,
230
+ ensureAgentsMd,
231
+ retireAgentsMd,
232
+ scaffoldAgentsMd,
233
+ };
@@ -25,7 +25,8 @@
25
25
  // Two attaches on ONE tee stack the same way: a second attach of a DIFFERENT path pushes a
26
26
  // layer on top of the first instead of replacing it, so a verb that runs another verb in
27
27
  // process (`omega test` running `omega deploy`) keeps teeing both files. Detach order is
28
- // LIFO, and the module-level `detach()` pops the newest layer.
28
+ // LIFO, and the module-level `detach()` pops the newest layer. A harness that runs a verb
29
+ // takes `mark()` first and calls the restore it returns after, never a blind `detach()`.
29
30
  //
30
31
  // Skipped in CI: a runner has its own log capture and wants no logs/ left in the workspace.
31
32
  // `attachInCI: true` opts out of that skip, for a sink whose file is the POINT rather than
@@ -142,8 +143,20 @@ function createTee() {
142
143
  return detach;
143
144
  }
144
145
 
146
+ // A harness around a verb restores to the mark instead of popping blind: a run can
147
+ // push one layer, several, or none, and only what it pushed comes off, newest first.
148
+ function mark() {
149
+ const kept = layers.slice();
150
+ return function restore() {
151
+ for (const layer of layers.slice().reverse()) {
152
+ if (!kept.includes(layer)) { layer.detach(); }
153
+ }
154
+ };
155
+ }
156
+
145
157
  return {
146
158
  attach,
159
+ mark,
147
160
  detach: () => {
148
161
  const newest = layers[layers.length - 1];
149
162
  if (newest) { newest.detach(); }
@@ -265,6 +278,7 @@ function attachLogFile(filePath, options) {
265
278
 
266
279
  module.exports = attachLogFile;
267
280
  module.exports.detach = singleton.detach;
281
+ module.exports.mark = singleton.mark;
268
282
  module.exports.stripAnsi = stripAnsi;
269
283
  module.exports.createTee = createTee;
270
284
  module.exports.createChildLog = createChildLog;