@omega.js/desktop 0.1.0 → 0.51.0

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 (267) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/css/tokens/_index.scss +1 -1
  3. package/dist/assets/themes/base/_includes/frontend/sections/account-section-header.html +4 -1
  4. package/dist/assets/themes/base/_includes/frontend/sections/footer.html +13 -6
  5. package/dist/assets/themes/base/_includes/frontend/sections/nav.html +10 -8
  6. package/dist/assets/themes/base/_includes/global/sections/account.html +3 -1
  7. package/dist/assets/themes/base/_includes/global/sections/app-sidebar.html +12 -8
  8. package/dist/assets/themes/base/_includes/global/sections/app-topbar.html +10 -6
  9. package/dist/assets/themes/base/_includes/global/sections/page-header.html +8 -4
  10. package/dist/assets/themes/base/_layouts/backend/pages/dashboard/index.html +24 -24
  11. package/dist/assets/themes/base/_layouts/frontend/pages/about.html +10 -10
  12. package/dist/assets/themes/base/_layouts/frontend/pages/account/index.html +35 -35
  13. package/dist/assets/themes/base/_layouts/frontend/pages/alternatives/index.html +4 -4
  14. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signin.html +1 -1
  15. package/dist/assets/themes/base/_layouts/frontend/pages/auth/signup.html +4 -4
  16. package/dist/assets/themes/base/_layouts/frontend/pages/contact.html +3 -3
  17. package/dist/assets/themes/base/_layouts/frontend/pages/download.html +34 -33
  18. package/dist/assets/themes/base/_layouts/frontend/pages/extension/index.html +11 -11
  19. package/dist/assets/themes/base/_layouts/frontend/pages/legal/document.html +1 -1
  20. package/dist/assets/themes/base/_layouts/frontend/pages/status.html +1 -1
  21. package/dist/assets/themes/base/_layouts/frontend/pages/team/index.html +9 -7
  22. package/dist/assets/themes/base/_layouts/frontend/pages/team/member.html +5 -3
  23. package/dist/assets/themes/base/_sections/about/letter/section.html +1 -1
  24. package/dist/assets/themes/base/_sections/about/letter/section.json5 +4 -4
  25. package/dist/assets/themes/base/_sections/marketing/bento/section.html +1 -1
  26. package/dist/assets/themes/base/_sections/marketing/bento/section.json5 +15 -15
  27. package/dist/assets/themes/base/_sections/marketing/cta/section.json5 +1 -1
  28. package/dist/assets/themes/base/_sections/marketing/hero/section.html +6 -6
  29. package/dist/assets/themes/base/_sections/marketing/hero/section.json5 +10 -10
  30. package/dist/assets/themes/base/_sections/marketing/product-demo/section.html +1 -1
  31. package/dist/assets/themes/base/_sections/marketing/product-demo/section.json5 +2 -2
  32. package/dist/assets/themes/base/_sections/marketing/stats/section.html +1 -1
  33. package/dist/assets/themes/base/_sections/marketing/stats/section.json5 +5 -5
  34. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.html +1 -1
  35. package/dist/assets/themes/base/_sections/marketing/trusted-by/section.json5 +2 -2
  36. package/dist/assets/themes/neobrutalism/_layouts/frontend/pages/index.html +10 -10
  37. package/dist/assets/themes/newsflash/_layouts/frontend/pages/index.html +10 -10
  38. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.html +2 -2
  39. package/dist/assets/themes/newsflash/_sections/marketing/desks/section.json5 +4 -4
  40. package/dist/build.js +69 -28
  41. package/dist/cli-run.js +20 -13
  42. package/dist/cli.js +12 -7
  43. package/dist/commands/build.js +0 -2
  44. package/dist/commands/deploy.js +116 -44
  45. package/dist/commands/finalize-release.js +5 -4
  46. package/dist/commands/launch.js +12 -7
  47. package/dist/commands/lib/deploy-precheck.js +66 -34
  48. package/dist/commands/lib/ensure-target.js +71 -11
  49. package/dist/commands/package.js +0 -1
  50. package/dist/commands/publish.js +11 -4
  51. package/dist/commands/release.js +99 -252
  52. package/dist/commands/runner.js +42 -5
  53. package/dist/commands/sign-windows.js +46 -15
  54. package/dist/commands/test.js +4 -3
  55. package/dist/commands/validate-certs.js +291 -115
  56. package/dist/config/page-template.html +4 -0
  57. package/dist/defaults/.github/workflows/build.yml +144 -64
  58. package/dist/defaults/AGENTS.md +3 -2
  59. package/dist/defaults/_.gitignore +4 -2
  60. package/dist/defaults/config/certs/README.md +25 -45
  61. package/dist/defaults/config/omega.json5 +66 -32
  62. package/dist/defaults/hooks/deploy/pre.js +10 -0
  63. package/dist/defaults/src/assets/scss/main.scss +12 -2
  64. package/dist/defaults/src/integrations/tray/index.js +1 -1
  65. package/dist/gulp/main.js +13 -25
  66. package/dist/gulp/tasks/audit.js +18 -6
  67. package/dist/gulp/tasks/build-config.js +181 -60
  68. package/dist/gulp/tasks/bundle.js +108 -36
  69. package/dist/gulp/tasks/release.js +86 -4
  70. package/dist/gulp/tasks/sass.js +11 -0
  71. package/dist/hooks/lib/notarize-tools.js +137 -0
  72. package/dist/hooks/notarize-artifacts.js +57 -0
  73. package/dist/hooks/notarize.js +59 -8
  74. package/dist/lib/auth-persistence.js +25 -0
  75. package/dist/lib/client-bridge.js +11 -8
  76. package/dist/lib/deep-link.js +15 -8
  77. package/dist/lib/protocol.js +7 -1
  78. package/dist/lib/restart-manager/install.js +6 -3
  79. package/dist/lib/sign-helpers/auto-unlock.js +65 -27
  80. package/dist/lib/sign-helpers/console-lock.js +34 -0
  81. package/dist/lib/sign-helpers/exec-with-limit.js +68 -0
  82. package/dist/lib/sign-helpers/resolve-icons.js +6 -4
  83. package/dist/lib/tray.js +7 -6
  84. package/dist/main.js +16 -0
  85. package/dist/preload.js +8 -0
  86. package/dist/renderer.js +16 -4
  87. package/dist/runner/job-started.js +104 -0
  88. package/dist/test/fixtures/consumer-app/config/omega.json5 +7 -0
  89. package/dist/test/fixtures/consumer-app/package.json +1 -1
  90. package/dist/test/fixtures/consumer-app/src/assets/js/components/main/index.js +7 -1
  91. package/dist/test/harness/main-entry.js +2 -1
  92. package/dist/test/harness/renderer-preload.js +12 -3
  93. package/dist/test/runners/boot.js +101 -11
  94. package/dist/test/runners/electron.js +1 -1
  95. package/dist/test/suites/boot/consumer-app-boots.test.js +54 -0
  96. package/dist/test/suites/build/audit.test.js +45 -6
  97. package/dist/test/suites/build/auth-persistence-resolve.test.js +119 -0
  98. package/dist/test/suites/build/auto-unlock.test.js +105 -0
  99. package/dist/test/suites/build/boot-runner-timeout.test.js +253 -0
  100. package/dist/test/suites/build/brand-scss.test.js +106 -0
  101. package/dist/test/suites/build/build-config.test.js +146 -36
  102. package/dist/test/suites/build/build-json-bake.test.js +245 -0
  103. package/dist/test/suites/build/build-verbs.test.js +48 -15
  104. package/dist/test/suites/build/build-workflow-jobs.test.js +190 -0
  105. package/dist/test/suites/build/cli.test.js +32 -10
  106. package/dist/test/suites/build/config-schema.test.js +4 -4
  107. package/dist/test/suites/build/console-lock.test.js +51 -0
  108. package/dist/test/suites/build/defaults-scaffold.test.js +71 -2
  109. package/dist/test/suites/build/deploy-direct.test.js +241 -0
  110. package/dist/test/suites/build/deploy-dispatch.test.js +287 -0
  111. package/dist/test/suites/build/deploy-hook.test.js +169 -0
  112. package/dist/test/suites/build/ensure-target.test.js +14 -2
  113. package/dist/test/suites/build/env-delivery.test.js +19 -10
  114. package/dist/test/suites/build/env-watch.test.js +18 -7
  115. package/dist/test/suites/build/esm-only-dependency.test.js +127 -0
  116. package/dist/test/suites/build/exec-with-limit.test.js +53 -0
  117. package/dist/test/suites/build/finalize-release.test.js +2 -2
  118. package/dist/test/suites/build/get-config.test.js +120 -8
  119. package/dist/test/suites/build/github-utils.test.js +12 -6
  120. package/dist/test/suites/build/license-stamp.test.js +6 -4
  121. package/dist/test/suites/build/manager.test.js +88 -68
  122. package/dist/test/suites/build/manifest-deps.test.js +116 -0
  123. package/dist/test/suites/build/merge-line-files.test.js +27 -4
  124. package/dist/test/suites/build/notarize-artifacts.test.js +135 -0
  125. package/dist/test/suites/build/notarize-tools.test.js +38 -0
  126. package/dist/test/suites/build/notarize.test.js +207 -0
  127. package/dist/test/suites/build/release-pipeline.test.js +38 -60
  128. package/dist/test/suites/build/release-skipped-upload.test.js +107 -0
  129. package/dist/test/suites/build/resolve-icons.test.js +35 -35
  130. package/dist/test/suites/build/runner-job-guard.test.js +182 -0
  131. package/dist/test/suites/build/runner.test.js +25 -2
  132. package/dist/test/suites/build/sentry.test.js +8 -3
  133. package/dist/test/suites/build/setup-scripts.test.js +3 -0
  134. package/dist/test/suites/build/sign-windows.test.js +92 -8
  135. package/dist/test/suites/build/test-stealth.test.js +17 -11
  136. package/dist/test/suites/build/url-helpers.test.js +37 -17
  137. package/dist/test/suites/build/validate-certs.test.js +428 -55
  138. package/dist/test/suites/build/validate-config.test.js +3 -3
  139. package/dist/test/suites/main/auth-flow.test.js +12 -0
  140. package/dist/test/suites/main/auth-persistence.test.js +14 -17
  141. package/dist/test/suites/main/auto-updater.test.js +2 -2
  142. package/dist/test/suites/main/boot-sequence.test.js +1 -1
  143. package/dist/test/suites/main/client-bridge.integration.test.js +5 -82
  144. package/dist/test/suites/main/client-bridge.test.js +19 -6
  145. package/dist/test/suites/main/deep-link.test.js +54 -0
  146. package/dist/test/suites/main/startup-paths-and-ua.test.js +1 -1
  147. package/dist/test/suites/main/url-helpers.test.js +81 -72
  148. package/dist/test/suites/renderer/cross-context-helpers.test.js +19 -16
  149. package/dist/utils/build-pipeline.js +10 -10
  150. package/dist/utils/github.js +12 -51
  151. package/dist/utils/load-env.js +66 -0
  152. package/dist/utils/mode-helpers.js +43 -111
  153. package/dist/utils/platform.js +37 -0
  154. package/dist/utils/runner-env.js +2 -1
  155. package/dist/utils/runner-job-guard.js +149 -0
  156. package/dist/utils/ship-keys.js +52 -0
  157. package/dist/utils/test-stealth.js +5 -3
  158. package/dist/utils/url-helpers.js +33 -17
  159. package/dist/vendor/config/bundle-id.js +53 -0
  160. package/dist/vendor/config/client-config.js +141 -0
  161. package/dist/vendor/config/company.js +334 -15
  162. package/dist/vendor/config/dev-facts.js +48 -0
  163. package/dist/vendor/config/env-delivery.js +231 -9
  164. package/dist/vendor/config/env-retired.js +137 -0
  165. package/dist/vendor/config/env-rules.js +22 -3
  166. package/dist/vendor/config/env-schema.js +234 -117
  167. package/dist/vendor/config/env.js +55 -26
  168. package/dist/vendor/config/environment.js +189 -0
  169. package/dist/vendor/config/hooks.js +13 -11
  170. package/dist/vendor/config/index.js +121 -44
  171. package/dist/vendor/config/load.js +366 -115
  172. package/dist/vendor/config/merge.js +2 -2
  173. package/dist/vendor/config/order.js +3 -3
  174. package/dist/vendor/config/platforms.js +276 -0
  175. package/dist/vendor/config/repo.js +226 -104
  176. package/dist/vendor/config/retired-keys.js +232 -27
  177. package/dist/vendor/config/schema.js +525 -99
  178. package/dist/vendor/config/site-global.js +63 -53
  179. package/dist/vendor/config/targets.js +187 -0
  180. package/dist/vendor/config/validate.js +119 -59
  181. package/dist/vendor/devkit/argv.js +118 -0
  182. package/dist/vendor/devkit/attach-log-file.js +21 -13
  183. package/dist/vendor/devkit/brand-tokens.js +278 -0
  184. package/dist/vendor/devkit/brand-version.js +264 -0
  185. package/dist/vendor/devkit/build-json.js +91 -0
  186. package/dist/vendor/devkit/bundle.js +48 -0
  187. package/dist/vendor/devkit/certificate-expiry.js +108 -0
  188. package/dist/vendor/devkit/certs.js +16 -194
  189. package/dist/vendor/devkit/ci-workflows.js +124 -8
  190. package/dist/vendor/devkit/cli-router.js +3 -3
  191. package/dist/vendor/devkit/defaults-engine.js +69 -7
  192. package/dist/vendor/devkit/deploy-follow.js +297 -0
  193. package/dist/vendor/devkit/deploy-precheck.js +23 -8
  194. package/dist/vendor/devkit/deploy-record.js +11 -27
  195. package/dist/vendor/devkit/deploy-snapshot.js +661 -0
  196. package/dist/vendor/devkit/deploy.js +445 -75
  197. package/dist/vendor/devkit/git-auth.js +73 -0
  198. package/dist/vendor/devkit/git-remote.js +95 -0
  199. package/dist/vendor/devkit/github-repo.js +290 -0
  200. package/dist/vendor/devkit/local.js +47 -0
  201. package/dist/vendor/devkit/merge-line-files.js +23 -16
  202. package/dist/vendor/devkit/omega-bin.js +18 -3
  203. package/dist/vendor/devkit/pack-local.js +391 -0
  204. package/dist/vendor/devkit/preludes/index.js +120 -0
  205. package/dist/vendor/devkit/preludes/origin-heal.js +156 -0
  206. package/dist/vendor/devkit/service-account.js +43 -0
  207. package/dist/vendor/devkit/ship-plan.js +112 -0
  208. package/dist/vendor/devkit/signing-env.js +180 -0
  209. package/dist/vendor/devkit/signing-tree.js +92 -0
  210. package/dist/vendor/devkit/target-seams.js +142 -0
  211. package/dist/vendor/devkit/target-secrets.js +235 -50
  212. package/dist/vendor/devkit/test/esm-only-fixture.js +48 -0
  213. package/dist/vendor/devkit/test/fixtures/esm-only-package/browser.js +19 -0
  214. package/dist/vendor/devkit/test/fixtures/esm-only-package/index.js +20 -0
  215. package/dist/vendor/devkit/test/fixtures/esm-only-package/package.json +13 -0
  216. package/dist/vendor/monitoring/env.js +20 -10
  217. package/dist/vendor/monitoring/main.js +1 -1
  218. package/dist/vendor/monitoring/preload.js +1 -1
  219. package/dist/vendor/monitoring/renderer.js +1 -1
  220. package/docs/analytics.md +1 -1
  221. package/docs/auto-updater.md +5 -5
  222. package/docs/boot-sequence.md +1 -1
  223. package/docs/build-system.md +15 -7
  224. package/docs/client-bridge.md +9 -7
  225. package/docs/config-schema.md +4 -4
  226. package/docs/css.md +8 -2
  227. package/docs/deep-link.md +12 -4
  228. package/docs/environment-detection.md +32 -24
  229. package/docs/hooks.md +3 -1
  230. package/docs/icons.md +7 -7
  231. package/docs/index.md +61 -23
  232. package/docs/installer-options.md +24 -21
  233. package/docs/logging.md +5 -5
  234. package/docs/releasing.md +25 -17
  235. package/docs/runner.md +40 -5
  236. package/docs/shared/brands.md +12 -6
  237. package/docs/shared/breaking-changes.md +375 -21
  238. package/docs/shared/config.md +757 -199
  239. package/docs/shared/deploys.md +194 -91
  240. package/docs/shared/icons.md +18 -0
  241. package/docs/shared/local-dev.md +24 -6
  242. package/docs/shared/logging.md +9 -6
  243. package/docs/shared/monitoring.md +27 -13
  244. package/docs/shared/publishing.md +19 -15
  245. package/docs/shared/rulings.md +2 -2
  246. package/docs/shared/testing.md +1 -1
  247. package/docs/shared/theming.md +26 -1
  248. package/docs/shared/translation.md +49 -7
  249. package/docs/shared/updates.md +3 -3
  250. package/docs/signing.md +59 -33
  251. package/docs/test-framework.md +10 -5
  252. package/docs/themes.md +15 -1
  253. package/package.json +18 -12
  254. package/bin/omega-desktop +0 -2
  255. package/dist/commands/push-secrets.js +0 -141
  256. package/dist/test/suites/build/deliver-certs.test.js +0 -95
  257. package/dist/test/suites/build/derive-signing-env.test.js +0 -122
  258. package/dist/test/suites/build/push-secrets.test.js +0 -226
  259. package/dist/test/suites/build/resolve-signing-cert.test.js +0 -342
  260. package/dist/utils/deliver-certs.js +0 -69
  261. package/dist/utils/derive-signing-env.js +0 -56
  262. package/dist/utils/resolve-signing-cert.js +0 -175
  263. package/dist/vendor/config/desktop-artifacts.js +0 -110
  264. package/dist/vendor/config/instances.js +0 -208
  265. /package/dist/defaults/config/icons/{macos → mac}/dmg.png +0 -0
  266. /package/dist/defaults/config/icons/{macos → mac}/icon.png +0 -0
  267. /package/dist/defaults/config/icons/{macos → mac}/tray.png +0 -0
@@ -11,11 +11,11 @@
11
11
  *
12
12
  * Brand-monorepo hierarchy: when projectDir is a target inside a brand
13
13
  * monorepo ({brand}/targets/{target}), the brand root's config/omega.json5 is the
14
- * brand layer under the target's file, and a brand stamped with
15
- * .omega/company.json inherits its company root's file underneath that.
16
- * Resolution for a target:
14
+ * brand layer under the target's file, and a brand naming a company
15
+ * (`company: { id }`) inherits that company's own config/omega.json5 underneath
16
+ * that (#677). Resolution for a target:
17
17
  *
18
- * schema defaults ← framework defaults ← company ← brand shared ← brand targets[target] ← local shared ← local targets[target]
18
+ * schema defaults ← framework defaults ← company ← company.<environment> ← brand shared ← brand targets[name] ← brand.<environment> ← local shared ← local targets[name] ← local.<environment>
19
19
  *
20
20
  * The bottom layer derives from the schema's own `default:` entries
21
21
  * ([#478](https://github.com/Omega-JS-Stack/omega/issues/478)) — the one home
@@ -23,21 +23,36 @@
23
23
  * framework genuinely differs.
24
24
  *
25
25
  * The company file layers exactly like the brand file (shared, then its
26
- * targets[target] entry) minus its `brands` key — company plumbing that means
27
- * nothing inside a brand.
26
+ * targets[name] entry) minus its `brands` key, which is company plumbing that
27
+ * means nothing inside a brand.
28
28
  *
29
- * Multi-instance targets: a targets[target] value may be an ARRAY of id'd
30
- * instance entries — the target dir names WHICH instance (targets/website-admin →
31
- * web/admin, canonical dir → main) and that instance's entry is the target
32
- * layer for this target (see instances.js; the single-object form applies to
33
- * every target of the type, unchanged).
29
+ * Every layer is TWO files, not one
30
+ * ([#856](https://github.com/Omega-JS-Stack/omega/issues/856)): its omega.json5
31
+ * and the `omega.<environment>.json5` overlay beside it, which wins over its
32
+ * OWN base and still loses to the layer above (each file keeping the
33
+ * shared-then-targets[name] split). The environment is envEnvironment()'s
34
+ * answer, exactly the vocabulary the `.env.<environment>` overlays are suffixed
35
+ * with (#586), so ONE word names the config overlay, the env overlay and the
36
+ * runtime's own environment. Only the RUNNING environment's overlay composes,
37
+ * and a missing overlay is nothing. A file named for anything else
38
+ * (`omega.staging.json5`) is not a layer at all, so nothing reads it and there
39
+ * is nothing to warn about. An overlay holds only the OVERRIDES: the validator
40
+ * judges the merged result, never an overlay on its own.
41
+ *
42
+ * Targets are keyed by NAME (#886): the target dir IS the name
43
+ * (targets/community → the `community` entry), and that entry is the target
44
+ * layer of the chain. A standalone project has no such dir, so its name is the
45
+ * single target of that framework's type its own file declares (a deployed
46
+ * backend staged from `api: { type: 'backend' }` is still the `api` target).
47
+ * The caller passes the framework TYPE it runs, and a name declared with a
48
+ * different type fails loud (see targets.js).
34
49
  *
35
50
  * "shared" = the file minus its `targets` key. A target entry may override
36
51
  * ANY shared key — same agnostic deep merge at every step (see merge.js), so
37
52
  * a per-surface Sentry DSN or analytics id is just
38
- * targets.<type>.monitoring.providers.sentry.dsn.
53
+ * targets.<name>.monitoring.providers.sentry.dsn.
39
54
  * Target-section keys land at the TOP LEVEL of the resolved config
40
- * (targets.desktop.platforms resolves to config.platforms); the merged
55
+ * (targets.desktop.platforms resolves to config.platforms, for a target NAMED desktop); the merged
41
56
  * `targets` map itself is kept on the result purely so enabled-target
42
57
  * enumeration survives resolution — settings are never read from it.
43
58
  *
@@ -52,12 +67,12 @@ const path = require('node:path');
52
67
  const JSON5 = require('json5');
53
68
 
54
69
  const { deepMerge, isPlainObject } = require('./merge.js');
55
- const { readCompanyRoot } = require('./company.js');
70
+ const { resolveCompany, recordBrand } = require('./company.js');
56
71
  const { findSecretKeys } = require('./secrets.js');
57
72
  const { validateConfig } = require('./validate.js');
58
73
  const { TARGETS } = require('./schema.js');
59
74
  const { schemaDefaults } = require('./defaults.js');
60
- const { resolveInstanceEntry, resolveInstanceUrl, instanceIdFromDirName, MAIN_INSTANCE } = require('./instances.js');
75
+ const { targetNameFromDir, targetUrl } = require('./targets.js');
61
76
 
62
77
  const FILE_NAME = 'omega.json5';
63
78
  const CONFIG_LOCATIONS = [
@@ -82,6 +97,48 @@ function resolveConfigPath(projectDir) {
82
97
  return hit || null;
83
98
  }
84
99
 
100
+ /**
101
+ * The environment an overlay chain composes for. A lane that KNOWS the
102
+ * environment its artifact is for (a production build, a deploy stage) names it
103
+ * ([#856](https://github.com/Omega-JS-Stack/omega/issues/856)), exactly the way
104
+ * composeTargetEnv() already takes one for the `.env.<environment>` overlay
105
+ * beside it (#586); anything else gets `fallback`. One vocabulary, so a name
106
+ * outside it is a caller defect and fails loud rather than resolving to no
107
+ * overlay at all.
108
+ * @param {string} [environment] - The environment the caller named, or undefined.
109
+ * @param {string|null} fallback - What an unnamed environment resolves to.
110
+ * @returns {string|null} The environment whose overlay composes, or null for none.
111
+ */
112
+ function resolveOverlayEnvironment(environment, fallback) {
113
+ // lazy require: env.js depends on this module (findBrandRoot)
114
+ const { ENV_ENVIRONMENTS } = require('./env.js');
115
+
116
+ if (environment === undefined || environment === null) return fallback;
117
+
118
+ if (!ENV_ENVIRONMENTS.includes(environment)) {
119
+ throw new Error(`Unknown environment "${environment}": must be one of [${ENV_ENVIRONMENTS.join(', ')}]`);
120
+ }
121
+
122
+ return environment;
123
+ }
124
+
125
+ /**
126
+ * ONE layer's environment overlay (#856): the `omega.<environment>.json5`
127
+ * beside its own base file, the config mirror of the `.env` + `.env.<environment>`
128
+ * pair (#586). Only ONE environment is ever asked for, so an overlay named for
129
+ * any other word is not a layer and is never read (nothing to warn about: it is
130
+ * simply not part of the chain).
131
+ * @param {string|null} basePath - The layer's omega.json5 path, or null when the layer has none.
132
+ * @param {string|null} environment - The environment whose overlay composes (one of ENV_ENVIRONMENTS), or null for none.
133
+ * @returns {string|null} Absolute overlay path, or null when there is no overlay.
134
+ */
135
+ function resolveOverlayPath(basePath, environment) {
136
+ if (!basePath || !environment) return null;
137
+
138
+ const overlay = path.join(path.dirname(basePath), `${path.basename(FILE_NAME, '.json5')}.${environment}.json5`);
139
+ return fs.existsSync(overlay) ? overlay : null;
140
+ }
141
+
85
142
  /**
86
143
  * Cheap probe: does this project have an omega.json5 at all? Frameworks use it
87
144
  * to fail soft in non-consumer dirs (seeded-empty config); tooling uses it as
@@ -156,32 +213,22 @@ function findBrandConfigPath(projectDir) {
156
213
  }
157
214
 
158
215
  /**
159
- * The COMPANY layer of the chain: a brand stamped with `.omega/company.json`
160
- * (written idempotently by company manage runs) inherits its company root's
161
- * omega.json5 as the layer between framework defaults and the brand file —
162
- * company-wide values (a shared monitoring org, an analytics account) are
163
- * authored once at the company root. The marker sits at the BRAND root, so
164
- * targets resolve it through their brand; a brand root (or standalone project)
165
- * reads its own. Same rule as the .env cascade (env.js).
216
+ * The BRAND ROOT every company question is asked from: a target resolves it
217
+ * through its brand, a brand root (or standalone project) answers for itself.
218
+ *
219
+ * Same normalization as findBrandRoot: the company is named by the brand's own
220
+ * config, never by anything inside the runtime cwd or the staged build output.
221
+ * Reading only `functions/` left a STANDALONE project resolving from dist/ (the
222
+ * view `omega test` loads) looking one level too low, so its company layer
223
+ * vanished ([#257](https://github.com/Omega-JS-Stack/omega/issues/257)).
166
224
  * @param {string} projectDir - Target dir, brand root, or a TARGET_SUBDIR of one (functions/, dist/).
167
- * @returns {string|null} Absolute company omega.json5 path, or null.
225
+ * @returns {string} Absolute brand (or standalone-project) root.
168
226
  */
169
- function findCompanyConfigPath(projectDir) {
227
+ function companyHostRoot(projectDir) {
170
228
  let dir = path.resolve(projectDir);
171
- // Same normalization as findBrandRoot: a marker is stamped at the target/brand
172
- // root, never inside the runtime cwd or the staged build output. Reading
173
- // only `functions/` left a STANDALONE project resolving from dist/ (the view
174
- // `omega test` loads) looking for the marker inside dist/, so its company
175
- // layer vanished ([#257](https://github.com/Omega-JS-Stack/omega/issues/257)).
176
229
  if (TARGET_SUBDIRS.includes(path.basename(dir))) dir = path.dirname(dir);
177
230
 
178
- const markerRoot = findBrandRoot(dir) || dir;
179
- const companyRoot = readCompanyRoot(markerRoot);
180
-
181
- // A root stamped at ITSELF would merge its own file in twice.
182
- if (!companyRoot || path.resolve(companyRoot) === markerRoot) return null;
183
-
184
- return resolveConfigPath(companyRoot);
231
+ return findBrandRoot(dir) || dir;
185
232
  }
186
233
 
187
234
  /**
@@ -255,6 +302,28 @@ function stripTargets(config) {
255
302
  return shared;
256
303
  }
257
304
 
305
+ // The `company` keys the LOADER fills (#677): authored, they would be
306
+ // overwritten at every load, so an author hears about it at the file.
307
+ const RESOLVED_COMPANY_KEYS = ['name', 'url', 'images'];
308
+
309
+ /**
310
+ * The company keys nobody types, refused where a human writes them: the BRAND
311
+ * file and the COMPANY file (#677). The local layer is deliberately exempt,
312
+ * because a target's `config/omega.json5` may be a staged compose output, which
313
+ * carries the RESOLVED facts on purpose (the deployed runtime has no registry
314
+ * and no company tree to re-resolve them from).
315
+ * @param {string|null} file - The layer's path, for the message.
316
+ * @param {object|null} data - The parsed layer.
317
+ */
318
+ function assertNoTypedCompany(file, data) {
319
+ if (!data || !isPlainObject(data.company)) return;
320
+
321
+ const typed = RESOLVED_COMPANY_KEYS.filter((key) => data.company[key] !== undefined);
322
+ if (typed.length) {
323
+ throw new Error(`${typed.map((key) => `company.${key}`).join(', ')} in ${file} is resolved from the company, delete it (#677): the brand types company: { id: '<parent brand.id>' } (or 'self') and the loader fills the rest`);
324
+ }
325
+ }
326
+
258
327
  /**
259
328
  * Raw-file hard fails, applied BEFORE any merge: secrets anywhere in the
260
329
  * file (requested target or not) and the legacy targets ARRAY (it would
@@ -273,6 +342,83 @@ function assertUsableRawFile(file, data) {
273
342
  }
274
343
  }
275
344
 
345
+ /**
346
+ * Each layer's environment overlay (#856), read beside its own base file, plus
347
+ * the raw-file gate every one of the six files passes. ONE copy for the two
348
+ * readers of the chain (`loadConfig` and `composeTargetConfig`), which resolved
349
+ * the same three paths, read the same three files and ran the same ten asserts
350
+ * in the same order.
351
+ *
352
+ * An overlay is judged by the SAME hard fails as the base beside it: it is the
353
+ * same authored file, so a secret (or a legacy targets array) in one is the
354
+ * same defect, with the same message naming the overlay. That is why the bases
355
+ * come in here too: the gate is one block, not two halves that can drift.
356
+ *
357
+ * @param {object} options - Options.
358
+ * @param {string|null} options.companyPath - The company layer's base file.
359
+ * @param {object|null} options.company - The parsed company layer.
360
+ * @param {string|null} options.brandPath - The brand layer's base file.
361
+ * @param {object|null} options.brand - The parsed brand layer.
362
+ * @param {string|null} options.localPath - The local layer's base file.
363
+ * @param {object|null} options.local - The parsed local layer.
364
+ * @param {string|null} options.environment - The environment the overlays are
365
+ * FOR; null reads no overlay at all.
366
+ * @returns {{ companyOverlay: object|null, brandOverlay: object|null, localOverlay: object|null }}
367
+ * Each layer's overlay, or null where that layer has none.
368
+ */
369
+ function resolveLayerOverlays({ companyPath, company, brandPath, brand, localPath, local, environment }) {
370
+ const companyOverlayPath = resolveOverlayPath(companyPath, environment);
371
+ const brandOverlayPath = resolveOverlayPath(brandPath, environment);
372
+ const localOverlayPath = resolveOverlayPath(localPath, environment);
373
+
374
+ const companyOverlay = companyOverlayPath ? readConfigFile(companyOverlayPath) : null;
375
+ const brandOverlay = brandOverlayPath ? readConfigFile(brandOverlayPath) : null;
376
+ const localOverlay = localOverlayPath ? readConfigFile(localOverlayPath) : null;
377
+
378
+ assertUsableRawFile(companyPath, company);
379
+ assertUsableRawFile(brandPath, brand);
380
+ assertUsableRawFile(localPath, local);
381
+ assertUsableRawFile(companyOverlayPath, companyOverlay);
382
+ assertUsableRawFile(brandOverlayPath, brandOverlay);
383
+ assertUsableRawFile(localOverlayPath, localOverlay);
384
+ assertNoTypedCompany(companyPath, company);
385
+ assertNoTypedCompany(brandPath, brand);
386
+ assertNoTypedCompany(companyOverlayPath, companyOverlay);
387
+ assertNoTypedCompany(brandOverlayPath, brandOverlay);
388
+
389
+ return { companyOverlay, brandOverlay, localOverlay };
390
+ }
391
+
392
+ /**
393
+ * WHICH target a STANDALONE project is (no brand root above it, so no dir to
394
+ * read the name off): the single declared target of the framework's type, else
395
+ * the type word. A deployed backend staged from `api: { type: 'backend' }` is
396
+ * the `api` target, so its entry is the target layer, `enabled` is true and its
397
+ * url derives, exactly as it does inside the brand it was staged from. Two
398
+ * targets of one type have no single answer, so the type word stands.
399
+ * @param {object} targets - The merged targets map.
400
+ * @param {string} target - The framework type this project runs.
401
+ * @returns {string} The target name.
402
+ */
403
+ function standaloneTargetName(targets, target) {
404
+ const matches = Object.keys(targets || {})
405
+ .filter((name) => isPlainObject(targets[name]) && targets[name].type === target);
406
+
407
+ return matches.length === 1 ? matches[0] : target;
408
+ }
409
+
410
+ /**
411
+ * ONE layer's contribution to a target's config: its `targets.<name>` entry,
412
+ * or null when that layer says nothing about this target. Every merge chain
413
+ * (loadConfig and composeTargetConfig alike) folds the layers through this.
414
+ * @param {object|null} layer - A raw config layer (company, brand, or local).
415
+ * @param {string|null} name - The resolved target name.
416
+ * @returns {object|null} The target layer, or null.
417
+ */
418
+ function targetLayerOf(layer, name) {
419
+ return layer && layer.targets && isPlainObject(layer.targets[name]) ? layer.targets[name] : null;
420
+ }
421
+
276
422
  /**
277
423
  * Enabled targets of a config (raw or resolved): the keys of its `targets`
278
424
  * object — key presence IS the enablement signal.
@@ -284,22 +430,46 @@ function getEnabledTargets(config) {
284
430
  return targets && typeof targets === 'object' ? Object.keys(targets) : [];
285
431
  }
286
432
 
433
+ /**
434
+ * The RESOLVED `company` section: the same keys the consumer typed (`{ id }`,
435
+ * and `{ webhooks }` when the brand opts out), filled with the company's public
436
+ * facts (#677). A brand with no company resolves to its OWN name and url under
437
+ * a null id, so no reader anywhere needs a fallback.
438
+ * @param {string} hostRoot - The brand root the company is named from.
439
+ * @param {object} config - The merged config (its `company` and `brand` blocks).
440
+ * @returns {{ id: string|null, name: string|null, url: string|null, images: object, webhooks: boolean }}
441
+ */
442
+ function companyFacts(hostRoot, config) {
443
+ const { id, name, url, images, webhooks } = resolveCompany(hostRoot, config);
444
+
445
+ return { id, name, url, images, webhooks };
446
+ }
447
+
287
448
  /**
288
449
  * Load + resolve a project's omega.json5.
289
450
  * @param {string} projectDir - The project root (target root in a brand monorepo).
290
- * @param {string} [target] - Canonical target name ('web', 'backend', ...). When
291
- * given, the target sections overlay the shared namespace. When omitted, the
292
- * brand + local files merge whole (targets map included) — the shape tools like
293
- * omega-manager's disperse want.
451
+ * @param {string} [target] - The framework TYPE this project runs ('web',
452
+ * 'backend', ...). When given, the target sections overlay the shared
453
+ * namespace. When omitted, the brand + local files merge whole (targets map
454
+ * included): the shape tools like omega-manager's disperse want.
294
455
  * @param {object} [options]
295
456
  * @param {object} [options.defaults] - Framework defaults, layered directly on
296
457
  * top of the schema defaults (#478) — only what this framework does differently.
297
- * @returns {{ config: object, errors: string[], warnings: string[], enabled: boolean|null, instance: string, files: { local: string, brand: string|null, company: string|null } }}
298
- * `enabled` = whether `target` is listed under `targets` (null when no target
299
- * was requested); schema `errors` are returned, not thrown — only secrets and
300
- * unusable files throw. `warnings` are advisory findings (e.g. >1 backend
301
- * instance); `instance` is the target-dir-resolved instance id ('main' outside
302
- * the multi-instance world).
458
+ * @param {string} [options.environment] - The environment this load is FOR (one
459
+ * of ENV_ENVIRONMENTS): which `omega.<environment>.json5` overlay composes
460
+ * ([#856](https://github.com/Omega-JS-Stack/omega/issues/856)). Every lane
461
+ * that produces a PRODUCTION artifact names it, the way stageFunctions()
462
+ * already names one for the `.env` overlay beside it (#586), because the
463
+ * ambient answer is the composing machine's and a build from a terminal
464
+ * resolves `development`. Omitted (a dev boot, a deployed runtime) = the
465
+ * running environment, envEnvironment().
466
+ * @returns {{ config: object, errors: string[], warnings: string[], enabled: boolean|null, name: string|null, files: { local: string, brand: string|null, company: string|null } }}
467
+ * `enabled` = whether the resolved NAME is listed under `targets` (null when
468
+ * no target was requested); schema `errors` are returned, not thrown; only
469
+ * secrets and unusable files throw. `warnings` are advisory findings (e.g. >1
470
+ * backend target); `name` is the target-dir-resolved target name (for a
471
+ * standalone project, the single declared target of that type, else the type
472
+ * word; null when no target was requested).
303
473
  */
304
474
  function loadConfig(projectDir, target, options) {
305
475
  options = options || {};
@@ -319,7 +489,6 @@ function loadConfig(projectDir, target, options) {
319
489
  localPath = resolveConfigPath(path.dirname(path.resolve(projectDir)));
320
490
  }
321
491
  const brandPath = findBrandConfigPath(projectDir);
322
- const companyPath = findCompanyConfigPath(projectDir);
323
492
 
324
493
  // The local-layer file is OPTIONAL inside a brand monorepo (Ian 2026-07-13:
325
494
  // the brand file's targets section IS the per-target home) — a target with
@@ -331,80 +500,130 @@ function loadConfig(projectDir, target, options) {
331
500
 
332
501
  const local = localPath ? readConfigFile(localPath) : {};
333
502
  const brand = brandPath ? readConfigFile(brandPath) : null;
334
- const company = companyPath ? readConfigFile(companyPath) : null;
335
-
336
- assertUsableRawFile(companyPath, company);
337
- assertUsableRawFile(brandPath, brand);
338
- assertUsableRawFile(localPath, local);
339
503
 
340
- const inherited = stripCompanyPlumbing(company);
504
+ // The company layer comes from the brand's typed `company: { id }` through the
505
+ // ONE resolver (#677), which also answers for a runner that received a
506
+ // generated layer instead of a machine to resolve on.
507
+ const company = resolveCompany(companyHostRoot(projectDir), brand || local);
508
+ const companyPath = company.configFile;
509
+
510
+ // Each layer's environment overlay (#856), found beside its own base file.
511
+ // The environment is the one the CALLER named when it named one (a lane that
512
+ // knows what its artifact is FOR), else this machine's ambient answer, which
513
+ // is what a dev boot and a deployed runtime both want.
514
+ // lazy require: env.js depends on this module (findBrandRoot)
515
+ const { envEnvironment } = require('./env.js');
516
+ const environment = resolveOverlayEnvironment(options.environment, envEnvironment());
517
+ const { companyOverlay, brandOverlay, localOverlay } = resolveLayerOverlays({
518
+ companyPath, company: company.config, brandPath, brand, localPath, local, environment,
519
+ });
520
+
521
+ const inherited = stripCompanyPlumbing(company.config);
341
522
 
342
523
  // ─── Resolve ─────────────────────────────────────────────────────────────
343
- const hasTargets = !!((inherited && inherited.targets) || (brand && brand.targets) || local.targets);
344
- const targets = deepMerge(inherited ? inherited.targets : null, brand ? brand.targets : null, local.targets);
345
-
346
- // Instance dimension (multi-instance targets): WHICH instance this target is
347
- // comes from its dir name (targets/website-admin → web/admin; the canonical
348
- // dir → main). Only brand-monorepo targets resolve through the walk — a
349
- // standalone project's dir name is arbitrary and always means main.
350
- let targetRoot = path.resolve(projectDir);
351
- if (TARGET_SUBDIRS.includes(path.basename(targetRoot))) {
352
- targetRoot = path.dirname(targetRoot);
524
+ // The chain, weakest first: every layer's base file followed by its own
525
+ // environment overlay (#856), which beats that base and still loses to the
526
+ // layer above. Each entry contributes shared, then targets[name], below.
527
+ const layers = [
528
+ inherited,
529
+ stripCompanyPlumbing(companyOverlay),
530
+ brand,
531
+ brandOverlay,
532
+ local,
533
+ localOverlay,
534
+ ];
535
+
536
+ const hasTargets = layers.some((layer) => !!(layer && layer.targets));
537
+ const targets = deepMerge(...layers.map((layer) => (layer ? layer.targets : null)));
538
+
539
+ // WHICH target this is comes from its dir name (#886): targets/community is
540
+ // the `community` entry. Only brand-monorepo targets resolve through the
541
+ // walk: a standalone project's dir name is arbitrary, so it is named by what
542
+ // its own file declares for the framework it runs.
543
+ const name = target ? (targetNameFromDir(projectDir) || standaloneTargetName(targets, target)) : null;
544
+
545
+ // A dir whose entry runs a DIFFERENT framework has no honest resolution: the
546
+ // caller would silently merge somebody else's target layer.
547
+ const declared = name ? targets[name] : null;
548
+ if (target && declared && declared.type && declared.type !== target) {
549
+ throw new Error(`targets.${name} is type ${declared.type}; this project runs the ${target} framework`);
353
550
  }
354
- const instance = target && brandPath ? instanceIdFromDirName(path.basename(targetRoot), target) : MAIN_INSTANCE;
355
551
 
356
552
  const config = target
357
553
  ? deepMerge(
358
554
  schemaDefaults(target),
359
555
  options.defaults,
360
- stripTargets(inherited),
361
- inherited && inherited.targets ? resolveInstanceEntry(inherited.targets[target], instance) : null,
362
- stripTargets(brand),
363
- brand && brand.targets ? resolveInstanceEntry(brand.targets[target], instance) : null,
364
- stripTargets(local),
365
- local.targets ? resolveInstanceEntry(local.targets[target], instance) : null,
556
+ ...layers.flatMap((layer) => [stripTargets(layer), targetLayerOf(layer, name)]),
366
557
  )
367
- : deepMerge(schemaDefaults(), options.defaults, inherited, brand, local);
558
+ : deepMerge(schemaDefaults(), options.defaults, ...layers);
368
559
 
369
560
  // Keep the merged targets map on the resolved config (presence = enabled)
370
561
  if (target && hasTargets) {
371
562
  config.targets = targets;
372
563
  }
373
564
 
374
- // The instance's own public URL (#588): an entry's explicit `url` already
375
- // merged to the top level above, and a bare `{ id: 'admin' }` derives
376
- // https://admin.<brand host> through the ONE resolver, landing in that same
377
- // place, so every reader of the resolved config (site-global's site.url, the
378
- // web deploy's host + CNAME) sees the instance's url and not the main site's.
379
- // `main` derives nothing: brand.url IS its url.
565
+ // The target's own public URL (#588): an entry's explicit `url` already
566
+ // merged to the top level above, and a bare `community: { type: 'web' }`
567
+ // derives https://community.<brand host> through the ONE resolver, landing in
568
+ // that same place, so every reader of the resolved config (site-global's
569
+ // site.url, the web deploy's host + CNAME) sees THIS target's url and not the
570
+ // main site's. A target named for its type derives nothing: brand.url IS its
571
+ // url.
380
572
  //
381
- // A brand.url OVERRIDE that reached this instance (the instance entry's own
382
- // `brand.url`, an instance target dir's local layer, a dev layer pointing at
383
- // localhost) IS the instance url already, never a base to stack the id on:
384
- // deriving there gave shop.shop.acme.test and https://admin.localhost:4000.
385
- // The test is whether the resolved brand.url still equals the BRAND layer's.
386
- if (target && instance !== MAIN_INSTANCE && !config.url) {
387
- const brandLayerUrl = (brand && brand.brand && brand.brand.url)
573
+ // A brand.url OVERRIDE that reached this target (the entry's own `brand.url`,
574
+ // the target dir's local layer, a dev layer pointing at localhost) IS the
575
+ // target url already, never a base to stack the name on: deriving there gave
576
+ // shop.shop.acme.test and https://admin.localhost:4000. The test is whether
577
+ // the resolved brand.url still equals the BRAND layer's.
578
+ if (target && name !== target && !config.url) {
579
+ // A layer's environment overlay IS that layer's statement of brand.url
580
+ // (#856), so a development brand.url is a brand-layer value like any
581
+ // other: the targets under it keep deriving their own names off it.
582
+ const brandLayerUrl = (brandOverlay && brandOverlay.brand && brandOverlay.brand.url)
583
+ || (brand && brand.brand && brand.brand.url)
584
+ || (companyOverlay && companyOverlay.brand && companyOverlay.brand.url)
388
585
  || (inherited && inherited.brand && inherited.brand.url)
586
+ // A standalone project has no brand layer above it, so its OWN shared
587
+ // brand.url IS the brand's: only a target entry below it overrides.
588
+ || (!brandPath && localOverlay && localOverlay.brand ? localOverlay.brand.url : null)
589
+ || (!brandPath && local.brand ? local.brand.url : null)
389
590
  || null;
390
591
  const resolvedUrl = (config.brand && config.brand.url) || null;
391
592
 
392
- const instanceUrl = resolvedUrl && resolvedUrl !== brandLayerUrl
593
+ const resolvedTargetUrl = resolvedUrl && resolvedUrl !== brandLayerUrl
393
594
  ? resolvedUrl
394
- : resolveInstanceUrl(targets ? targets[target] : null, instance, config);
595
+ : targetUrl(config, name);
395
596
 
396
- if (instanceUrl) {
397
- config.url = instanceUrl;
597
+ if (resolvedTargetUrl) {
598
+ config.url = resolvedTargetUrl;
398
599
  }
399
600
  }
400
601
 
401
602
  const enabled = target
402
- ? hasTargets && Object.prototype.hasOwnProperty.call(targets, target)
603
+ ? hasTargets && Object.prototype.hasOwnProperty.call(targets, name)
403
604
  : null;
404
605
 
405
606
  const { errors, warnings } = validateConfig(config, { target });
406
607
 
407
- return { config, errors, warnings, enabled, instance, files: { local: localPath, brand: brandPath, company: companyPath } };
608
+ // The `company` section is RESOLVED, never typed past its `id` (#677): the
609
+ // same key the consumer wrote comes back filled, so every reader of a parent
610
+ // fact (the footer's credit, an email wordmark, the in-house ads api) reads
611
+ // one shape whether the brand has a company, IS one, or stands alone. Filled
612
+ // AFTER validation, so a typed name/url/images is still the author's key when
613
+ // the validator judges it.
614
+ config.company = companyFacts(companyHostRoot(projectDir), config);
615
+
616
+ // Every brand run writes its own line in the machine registry, which is what
617
+ // makes a sibling brand's `company: { id }` resolvable here without anyone
618
+ // maintaining a map (#677). Never fails the load.
619
+ recordBrand({
620
+ id: config.brand && config.brand.id,
621
+ root: companyHostRoot(projectDir),
622
+ name: config.brand && config.brand.name,
623
+ url: config.brand && config.brand.url,
624
+ });
625
+
626
+ return { config, errors, warnings, enabled, name, files: { local: localPath, brand: brandPath, company: companyPath } };
408
627
  }
409
628
 
410
629
  /**
@@ -415,30 +634,49 @@ function loadConfig(projectDir, target, options) {
415
634
  * interleave (company shared ← company targets[target] ← brand shared ← brand
416
635
  * targets[target] ← local shared ← local targets[target]) is frozen into the
417
636
  * shared namespace: the deployed
418
- * runtime's own `deepMerge(defaults, shared, targets[target])` then yields
419
- * EXACTLY the local resolution. `targets` keeps presence-only keys
420
- * (presence = enabled; every value is already folded in, so nothing
421
- * re-applies above the frozen interleave — a raw merged targets map would
422
- * let a brand-target value beat a local-shared one, flipping the chain).
637
+ * runtime's own `deepMerge(defaults, shared, targets[name])` then yields
638
+ * EXACTLY the local resolution. `targets` keeps presence-and-type keys
639
+ * (presence = enabled, and the `type` every entry must declare; every other
640
+ * value is already folded in, so nothing re-applies above the frozen
641
+ * interleave: a raw merged targets map would let a brand-target value beat a
642
+ * local-shared one, flipping the chain).
423
643
  * Framework defaults are NOT baked in: the deployed runtime applies its
424
644
  * own, so defaults evolve with the shipped package, not the deploy moment.
425
645
  *
646
+ * Environment overlays (#856) compose only for an environment the CALLER named:
647
+ * a compose is a BUILD-time op over the authored layers, and the environment an
648
+ * artifact is FOR is the LANE's answer, never the composing machine's (`omega
649
+ * deploy` from a terminal resolves `development`, which would bake a
650
+ * development override into a production upload). So the ambient answer is
651
+ * never read here, and a compose told nothing freezes the base layers alone.
652
+ * The knob is the one stageFunctions() already takes for the `.env` overlay
653
+ * beside it, spelled the same: `options.environment`.
654
+ *
426
655
  * The local-layer file is OPTIONAL inside a brand monorepo (same rule as
427
656
  * loadConfig since cp121c): a target with no omega.json5 of its own composes
428
657
  * from the brand file alone. Standalone projects still require their file.
429
658
  *
430
659
  * @param {string} projectDir - Target root or one of its TARGET_SUBDIRS (functions/, dist/).
431
660
  * @param {string} target - Canonical target the upload serves ('backend').
661
+ * @param {object} [options]
662
+ * @param {string} [options.environment] - The environment the upload is FOR (one
663
+ * of ENV_ENVIRONMENTS): which `omega.<environment>.json5` overlay is frozen
664
+ * into the composed file. Omitted = no overlay at all.
432
665
  * @returns {{ config: object, files: { local: string|null, brand: string|null, company: string|null } }}
433
666
  * `files.brand` null = no brand layer above the target (already self-contained);
434
667
  * `files.local` null = the target rides the brand file alone; `files.company`
435
- * null = the brand is not stamped into a company workspace.
668
+ * null = the brand names no company with a tree on this machine.
436
669
  */
437
- function composeTargetConfig(projectDir, target) {
670
+ function composeTargetConfig(projectDir, target, options) {
671
+ options = options || {};
672
+
438
673
  if (!TARGETS.includes(target)) {
439
674
  throw new Error(`Unknown target "${target}" — must be one of [${TARGETS.join(', ')}]`);
440
675
  }
441
676
 
677
+ // No fallback: an unnamed environment composes NO overlay (see above).
678
+ const environment = resolveOverlayEnvironment(options.environment, null);
679
+
442
680
  // Compose is a BUILD-time op over the AUTHORED layers: a TARGET_SUBDIR
443
681
  // (functions/, dist/) normalizes up to its target root, so a previously-staged
444
682
  // config/omega.json5 (compose OUTPUT) can never read back in as a local
@@ -450,40 +688,53 @@ function composeTargetConfig(projectDir, target) {
450
688
 
451
689
  const localPath = resolveConfigPath(targetRoot);
452
690
  const brandPath = findBrandConfigPath(targetRoot);
453
- const companyPath = findCompanyConfigPath(targetRoot);
454
691
  if (!localPath && !brandPath) {
455
692
  throw new Error(`No ${FILE_NAME} found under ${projectDir} (looked in ${CONFIG_LOCATIONS.join(', ')})`);
456
693
  }
457
694
 
458
695
  const local = localPath ? readConfigFile(localPath) : {};
459
696
  const brand = brandPath ? readConfigFile(brandPath) : null;
460
- const company = companyPath ? readConfigFile(companyPath) : null;
461
697
 
462
- assertUsableRawFile(companyPath, company);
463
- assertUsableRawFile(brandPath, brand);
464
- assertUsableRawFile(localPath, local);
698
+ const company = resolveCompany(companyHostRoot(targetRoot), brand || local);
699
+ const companyPath = company.configFile;
465
700
 
466
- const inherited = stripCompanyPlumbing(company);
701
+ // Each layer's overlay for THIS environment (#856), beside its own base file,
702
+ // read and gated by the same one helper loadConfig uses.
703
+ const { companyOverlay, brandOverlay, localOverlay } = resolveLayerOverlays({
704
+ companyPath, company: company.config, brandPath, brand, localPath, local, environment,
705
+ });
467
706
 
468
- // Same instance dimension as loadConfig: the target dir names the instance
469
- // whose entry is this compose's target layer (main outside a brand).
470
- const instance = brandPath ? instanceIdFromDirName(path.basename(targetRoot), target) : MAIN_INSTANCE;
707
+ const inherited = stripCompanyPlumbing(company.config);
708
+ const inheritedOverlay = stripCompanyPlumbing(companyOverlay);
709
+
710
+ // The chain, weakest first: every layer's base file followed by its own
711
+ // environment overlay (#856), exactly the order loadConfig folds them in.
712
+ const layers = [inherited, inheritedOverlay, brand, brandOverlay, local, localOverlay];
713
+
714
+ // Same collapse as loadConfig: the target dir NAMES the entry that is this
715
+ // compose's target layer (outside a brand, the file's single target of this
716
+ // framework's type).
717
+
718
+ const mergedTargets = deepMerge(...layers.map((layer) => (layer ? layer.targets : null)));
719
+ const name = targetNameFromDir(targetRoot) || standaloneTargetName(mergedTargets, target);
471
720
 
472
721
  const config = deepMerge(
473
- stripTargets(inherited),
474
- inherited && inherited.targets ? resolveInstanceEntry(inherited.targets[target], instance) : null,
475
- stripTargets(brand),
476
- brand && brand.targets ? resolveInstanceEntry(brand.targets[target], instance) : null,
477
- stripTargets(local),
478
- local.targets ? resolveInstanceEntry(local.targets[target], instance) : null,
722
+ ...layers.flatMap((layer) => [stripTargets(layer), targetLayerOf(layer, name)]),
479
723
  );
480
724
 
481
- const hasTargets = !!((inherited && inherited.targets) || (brand && brand.targets) || local.targets);
725
+ const hasTargets = layers.some((layer) => !!(layer && layer.targets));
482
726
  if (hasTargets) {
483
- const targets = deepMerge(inherited ? inherited.targets : null, brand ? brand.targets : null, local.targets);
484
- config.targets = Object.fromEntries(Object.keys(targets).map((name) => [name, {}]));
727
+ config.targets = Object.fromEntries(Object.keys(mergedTargets).map((entryName) => {
728
+ const entry = mergedTargets[entryName];
729
+ return [entryName, isPlainObject(entry) && entry.type ? { type: entry.type } : {}];
730
+ }));
485
731
  }
486
732
 
733
+ // The resolved company rides the upload like every other layer above it: the
734
+ // deployed runtime has no registry and no company tree, so the facts have to
735
+ // be frozen in here (#677).
736
+ config.company = companyFacts(companyHostRoot(targetRoot), config);
737
+
487
738
  return { config, files: { local: localPath, brand: brandPath, company: companyPath } };
488
739
  }
489
740