@omega.js/desktop 0.50.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 +3 -3
  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 +1 -1
  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
@@ -33,17 +33,19 @@
33
33
  * declaring a desktop target derives nothing, since the release may not
34
34
  * exist yet. WHICH repo those links address is not decided here: it is
35
35
  * [repo.js](repo.js)'s `releasesRepo`, the one home of the brand's public
36
- * releases repo (#799).
36
+ * releases repo (#799/#883): `<brand.id>-releases` under the brand's one
37
+ * `repo.org`, with no override key anywhere.
37
38
  *
38
39
  * Beside the hub sits `downloads` ([#620](https://github.com/Omega-JS-Stack/omega/issues/620)):
39
- * one DIRECT-download URL per artifact, so a download button hands over a file
40
- * instead of a GitHub page. The filenames are
41
- * [desktop-artifacts.js](desktop-artifacts.js)'s — the same rule
42
- * @omega.js/desktop packages under — and they carry no version, so releasing a
43
- * desktop build never touches the website.
40
+ * one DIRECT-download URL per FORMAT, so a download button hands over a file
41
+ * instead of a GitHub page. Both the format keys and the filenames are
42
+ * [platforms.js](platforms.js)'s, the same table @omega.js/desktop packages
43
+ * under, and they carry no version, so releasing a desktop build never touches
44
+ * the website. A `store` format (the snap) has no file: its URL is the store
45
+ * page itself ([#867](https://github.com/Omega-JS-Stack/omega/issues/867)).
44
46
  */
45
47
 
46
- const { desktopProductName, desktopArtifactNames } = require('./desktop-artifacts.js');
48
+ const { enabledFormats, desktopProductName, desktopArtifactNames, sanitizeProductName } = require('./platforms.js');
47
49
  const { releasesRepo } = require('./repo.js');
48
50
 
49
51
  // Keys that are resolution machinery, not site content
@@ -56,11 +58,10 @@ const MACHINERY_KEYS = ['targets', 'enabled'];
56
58
  * `releases.enabled: false` always suppresses. On the pipeline's second
57
59
  * toSiteGlobal pass the raw block is gone, so a curated `releasesUrl` is
58
60
  * itself the opt-in (idempotence).
59
- * @param {object} config - resolved config object
61
+ * @param {object} desktop - the desktop target's entry
60
62
  * @returns {boolean}
61
63
  */
62
- function desktopReleasesEnabled(config) {
63
- const desktop = targetEntry(config, 'desktop');
64
+ function desktopReleasesEnabled(desktop) {
64
65
  if (!desktop) return false;
65
66
  if (desktop.releases) return desktop.releases.enabled !== false;
66
67
 
@@ -72,92 +73,101 @@ function desktopReleasesEnabled(config) {
72
73
  * public releases repo, whose address `@omega.js/config`'s `releasesRepo` owns
73
74
  * (#799). Nothing is derived here; a repo the derivation cannot address (no
74
75
  * owner, no brand.id) yields no URL.
76
+ * @param {object} desktop - the desktop target's entry
75
77
  * @param {object} config - resolved config object
76
78
  * @returns {string|undefined}
77
79
  */
78
- function desktopReleasesUrl(config) {
79
- const desktop = targetEntry(config, 'desktop');
80
-
80
+ function desktopReleasesUrl(desktop, config) {
81
81
  // Idempotence: the build pipeline applies toSiteGlobal more than once
82
82
  // (consumer.js loadSiteData, then engine.js configureOmega). On a second pass
83
83
  // the raw releases block is gone, so the curated view's own URL is the
84
- // authority: re-deriving would rename a brand's explicit releases.repo to the
85
- // `<brand.id>-releases` default behind its back.
84
+ // authority: re-deriving off a config that no longer carries the block would
85
+ // drop the links a brand already has.
86
86
  if (!desktop?.releases && desktop?.releasesUrl) return desktop.releasesUrl;
87
87
 
88
- const { repo } = releasesRepo(config);
88
+ const releases = releasesRepo(config);
89
89
 
90
- return repo ? `https://github.com/${repo}/releases/latest` : undefined;
90
+ return releases ? `https://github.com/${releases.slug}/releases/latest` : undefined;
91
91
  }
92
92
 
93
93
  /**
94
- * The direct-download URL of every desktop artifact, keyed platform → artifact
95
- * (#620). GitHub's `/releases/latest/download/<asset>` serves the newest
96
- * release's asset by name, which only holds because the names are versionless.
94
+ * The download URL of every desktop FORMAT, keyed platform then format (#620,
95
+ * #867). An `asset` format links the file itself: GitHub's
96
+ * `/releases/latest/download/<asset>` serves the newest release's asset by
97
+ * name, which only holds because the names are versionless. A `store` format
98
+ * links the store page, because the store holds the file.
99
+ * @param {object} desktop - the desktop target's entry
97
100
  * @param {object} config - resolved config object
98
101
  * @param {string} releasesUrl - the derived releases hub URL
99
102
  * @returns {object|undefined}
100
103
  */
101
- function desktopDownloads(config, releasesUrl) {
102
- const desktop = targetEntry(config, 'desktop');
103
-
104
+ function desktopDownloads(desktop, config, releasesUrl) {
104
105
  // Idempotence, same trap as the URL above: the second toSiteGlobal pass sees
105
106
  // the curated view, which carries no app block and no brand-derived product
106
- // name — the curated URLs are the authority by then.
107
+ // name, so the curated URLs are the authority by then.
107
108
  if (desktop?.downloads) return desktop.downloads;
108
109
 
109
- // No product name (an invalid config — brand.name is required) derives
110
- // nothing: a guessed filename is a download button that 404s.
111
- const names = desktopArtifactNames(desktopProductName({ app: desktop?.app, brand: config?.brand }));
110
+ // No product name (an invalid config: brand.name is required) derives
111
+ // nothing, because a guessed filename is a download button that 404s.
112
+ const productName = desktopProductName({ app: desktop?.app, brand: config?.brand });
113
+ const names = desktopArtifactNames(productName);
112
114
  if (!Object.keys(names).length) return undefined;
113
115
 
116
+ // Only what this brand SHIPS gets a link: a dropped platform or format must
117
+ // not render a button that 404s (#867).
114
118
  const downloads = {};
115
- for (const [platform, artifacts] of Object.entries(names)) {
116
- downloads[platform] = {};
117
- for (const [artifact, filename] of Object.entries(artifacts)) {
118
- downloads[platform][artifact] = `${releasesUrl}/download/${filename}`;
119
- }
119
+ for (const { platform, format } of enabledFormats(desktop, 'desktop')) {
120
+ // An asset links its file; the one store format (the snap) links the Snap
121
+ // Store listing, whose slug is the snap's own name, which electron-builder
122
+ // derives from the executable name: the sanitized product name, lowercased.
123
+ const filename = names[platform]?.[format];
124
+ const url = filename
125
+ ? `${releasesUrl}/download/${filename}`
126
+ : `https://snapcraft.io/${sanitizeProductName(productName).toLowerCase()}`;
127
+
128
+ downloads[platform] = downloads[platform] || {};
129
+ downloads[platform][format] = url;
120
130
  }
121
131
 
122
- return downloads;
132
+ return Object.keys(downloads).length ? downloads : undefined;
123
133
  }
124
134
 
125
135
  /**
126
- * A target's config entry when it is the plain-object form. Array-form
127
- * (multi-instance) targets return undefined: which instance's facts belong
128
- * on the site is ambiguous, so instance-form brands supply explicit page
129
- * maps instead of getting a silently wrong derivation.
130
- * @param {object} config - resolved config object
131
- * @param {string} name - target key
132
- * @returns {object|undefined}
136
+ * Whether an entry is a target of this TYPE (#886): the declared `type`, or,
137
+ * on the second toSiteGlobal pass, the curated fact only that type derives.
138
+ * The NAME is never the test, so a brand that names its desktop target `app`
139
+ * still gets `site.targets.app.downloads`.
140
+ * @param {object} entry - a raw or curated targets entry
141
+ * @param {string} type - 'desktop' | 'extension'
142
+ * @param {string} curatedKey - the curated key that type alone produces
143
+ * @returns {boolean}
133
144
  */
134
- function targetEntry(config, name) {
135
- const entry = config?.targets?.[name];
136
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return undefined;
137
- return entry;
145
+ function isTargetOfType(entry, type, curatedKey) {
146
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return false;
147
+ return entry.type === type || entry[curatedKey] !== undefined;
138
148
  }
139
149
 
140
150
  /**
141
151
  * Curate one target's display-safe view. Presence = enabled; per-type
142
152
  * extras are allow-listed here, never spread from the raw entry.
143
- * @param {string} name - target key (web, backend, desktop, extension, …)
153
+ * @param {object} entry - the target's own config entry
144
154
  * @param {object} config - resolved config object
145
155
  * @returns {object}
146
156
  */
147
- function curateTarget(name, config) {
157
+ function curateTarget(entry, config) {
148
158
  const view = { enabled: true };
149
159
 
150
- if (name === 'desktop' && desktopReleasesEnabled(config)) {
151
- const releasesUrl = desktopReleasesUrl(config);
160
+ if (isTargetOfType(entry, 'desktop', 'releasesUrl') && desktopReleasesEnabled(entry)) {
161
+ const releasesUrl = desktopReleasesUrl(entry, config);
152
162
  if (releasesUrl) {
153
163
  view.releasesUrl = releasesUrl;
154
- const downloads = desktopDownloads(config, releasesUrl);
164
+ const downloads = desktopDownloads(entry, config, releasesUrl);
155
165
  if (downloads) view.downloads = downloads;
156
166
  }
157
167
  }
158
168
 
159
- if (name === 'extension') {
160
- const listings = targetEntry(config, 'extension')?.listings;
169
+ if (isTargetOfType(entry, 'extension', 'listings')) {
170
+ const { listings } = entry;
161
171
  if (listings) {
162
172
  const curated = {};
163
173
  for (const [store, entry] of Object.entries(listings)) {
@@ -194,8 +204,8 @@ function toSiteGlobal(config) {
194
204
  const targets = config?.targets;
195
205
  if (targets && typeof targets === 'object') {
196
206
  site.targets = {};
197
- for (const name of Object.keys(targets)) {
198
- site.targets[name] = curateTarget(name, config);
207
+ for (const [name, entry] of Object.entries(targets)) {
208
+ site.targets[name] = curateTarget(entry, config);
199
209
  }
200
210
  }
201
211
 
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The `targets` map: every key is a target NAME, every entry declares its
3
+ * `type` ([#886](https://github.com/Omega-JS-Stack/omega/issues/886)).
4
+ *
5
+ * The name is the whole addressing story: it is the folder
6
+ * (`targets/<name>`), the `--target=<name>` word, and the derived-repo
7
+ * suffix. The type says WHICH framework runs there, so a brand runs two web
8
+ * targets by declaring two names:
9
+ *
10
+ * targets: {
11
+ * web: { type: 'web', url: 'https://somiibo.com' },
12
+ * community: { type: 'web', url: 'https://community.somiibo.com' },
13
+ * backend: { type: 'backend' },
14
+ * docs: { type: 'custom' },
15
+ * }
16
+ *
17
+ * There is no id, no folder key, and no name table: the key IS the answer, so
18
+ * the dir walk and the path derivation are each one line. This module is the
19
+ * ONE home of that derivation, imported by every framework and the manager,
20
+ * never copied.
21
+ */
22
+
23
+ const path = require('node:path');
24
+
25
+ const { TARGETS, CUSTOM_TARGET_TYPE } = require('./schema.js');
26
+
27
+ // A target name is a folder name, so it must be a dir-safe slug
28
+ const TARGET_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
29
+
30
+ // Every legal `type`: the framework targets plus the custom one whose verbs
31
+ // come from its own package.json scripts (#603)
32
+ const TARGET_TYPES = [...TARGETS, CUSTOM_TARGET_TYPE];
33
+
34
+ /**
35
+ * Every declared target as `{ name, type, ...entry }`, in config order.
36
+ * @param {object} config - A raw or resolved config carrying `targets`.
37
+ * @returns {Array<object>} The entries; [] when the config declares none.
38
+ * @throws {Error} When an entry carries no `type`, or a type no framework owns.
39
+ */
40
+ function targetEntries(config) {
41
+ const targets = config && config.targets;
42
+ if (!targets || typeof targets !== 'object') return [];
43
+
44
+ return Object.keys(targets).map((name) => {
45
+ const entry = targets[name] || {};
46
+
47
+ if (!TARGET_TYPES.includes(entry.type)) {
48
+ throw new Error(`targets.${name} must declare a type: one of [${TARGET_TYPES.join(', ')}] (got ${entry.type === undefined ? 'nothing' : JSON.stringify(entry.type)})`);
49
+ }
50
+
51
+ return { ...entry, name, type: entry.type };
52
+ });
53
+ }
54
+
55
+ /**
56
+ * The declared targets of one type, in config order.
57
+ * @param {object} config - A raw or resolved config carrying `targets`.
58
+ * @param {string} type - A value from TARGET_TYPES.
59
+ * @returns {Array<object>} The matching entries.
60
+ */
61
+ function targetsOfType(config, type) {
62
+ return targetEntries(config).filter((entry) => entry.type === type);
63
+ }
64
+
65
+ /**
66
+ * Does this config declare a target of that TYPE? The ONE presence gate, for
67
+ * every "only when this brand runs a backend/desktop/mobile" check: a key
68
+ * lookup answers the wrong question now that a backend may be named `api`
69
+ * (#886). Raw, unvalidated configs answer too, so a missing or unknown `type`
70
+ * is simply "no", never a throw.
71
+ * @param {object} config - A raw or resolved config carrying `targets`.
72
+ * @param {string} type - A value from TARGET_TYPES.
73
+ * @returns {boolean} True when at least one entry declares that type.
74
+ */
75
+ function hasTargetOfType(config, type) {
76
+ const targets = config && config.targets;
77
+ if (!targets || typeof targets !== 'object') return false;
78
+
79
+ return Object.values(targets).some((entry) => entry && entry.type === type);
80
+ }
81
+
82
+ /**
83
+ * A target's folder, relative to the brand root: the name IS the folder.
84
+ * @param {object} config - A raw or resolved config carrying `targets`.
85
+ * @param {string} name - The target name.
86
+ * @returns {string} `targets/<name>` (posix separators).
87
+ * @throws {Error} When the config declares no target of that name.
88
+ */
89
+ function targetPath(config, name) {
90
+ const declared = config && config.targets ? Object.keys(config.targets) : [];
91
+
92
+ if (!declared.includes(name)) {
93
+ throw new Error(`No target "${name}" is declared: this brand declares [${declared.join(', ')}]`);
94
+ }
95
+
96
+ return `targets/${name}`;
97
+ }
98
+
99
+ /**
100
+ * WHICH target a project dir is: the target root's basename when the dir (or
101
+ * one of its TARGET_SUBDIRS, functions/ and dist/) sits inside a brand
102
+ * monorepo. A standalone project's dir name is arbitrary, so it names nothing.
103
+ * @param {string} projectDir - Target root, or one of its TARGET_SUBDIRS.
104
+ * @returns {string|null} The target name, or null outside a brand.
105
+ */
106
+ function targetNameFromDir(projectDir) {
107
+ // Local require to avoid a load-time cycle (load.js requires this module)
108
+ const { findBrandRoot, TARGET_SUBDIRS } = require('./load.js');
109
+
110
+ let targetRoot = path.resolve(projectDir);
111
+ if (TARGET_SUBDIRS.includes(path.basename(targetRoot))) {
112
+ targetRoot = path.dirname(targetRoot);
113
+ }
114
+
115
+ if (!findBrandRoot(targetRoot)) return null;
116
+ return path.basename(targetRoot);
117
+ }
118
+
119
+ /**
120
+ * The host `brand.url` names, EXACTLY as it states it: a `www.` label is kept
121
+ * (a www brand is a www brand), any path is dropped, a port survives. A
122
+ * scheme-less value is read as https.
123
+ * @param {string} url - A brand URL.
124
+ * @returns {string} The host, or '' when the value is not a URL.
125
+ */
126
+ function brandHost(url) {
127
+ try {
128
+ return new URL(url.includes('://') ? url : `https://${url}`).host;
129
+ } catch {
130
+ return '';
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The live URL of a target: its entry's `url`, a target-scoped `brand.url`
136
+ * override, then the derivation. A target NAMED for its type is the brand
137
+ * itself (`brand.url`); any other name is that name AS A SUBDOMAIN of the
138
+ * brand host (#588), which is what makes `community: { type: 'web' }` a
139
+ * complete declaration. Nothing is half-derived: no usable brand.url means
140
+ * null, never `https://community.`.
141
+ * @param {object} config - The (resolved) config carrying brand.url and targets.
142
+ * @param {string} name - The target name.
143
+ * @returns {string|null} The target's URL, or null when none is known.
144
+ */
145
+ function targetUrl(config, name) {
146
+ const entry = config && config.targets ? config.targets[name] : null;
147
+
148
+ if (entry && entry.url) return entry.url;
149
+ if (entry && entry.brand && entry.brand.url) return entry.brand.url;
150
+
151
+ const brandUrl = config && config.brand ? config.brand.url : null;
152
+ if (!brandUrl) return null;
153
+ if (entry && name === entry.type) return brandUrl;
154
+
155
+ const host = brandHost(brandUrl);
156
+ return host ? `https://${name}.${host}` : null;
157
+ }
158
+
159
+ /**
160
+ * A target's deterministic dev-port offset: its position among the targets of
161
+ * ITS type, so N same-type targets get N side-by-side ports off the same
162
+ * classic base. Unknown names offset 0, and the N7 bump-if-taken allocator
163
+ * still guarantees a free port.
164
+ * @param {object} config - A raw or resolved config carrying `targets`.
165
+ * @param {string} name - The target name.
166
+ * @returns {number} Zero-based offset.
167
+ */
168
+ function targetPortOffset(config, name) {
169
+ const entry = config && config.targets ? config.targets[name] : null;
170
+ if (!entry || !entry.type) return 0;
171
+
172
+ const index = targetsOfType(config, entry.type).findIndex((sibling) => sibling.name === name);
173
+ return index > 0 ? index : 0;
174
+ }
175
+
176
+ module.exports = {
177
+ TARGET_NAME_PATTERN,
178
+ TARGET_TYPES,
179
+ targetEntries,
180
+ targetsOfType,
181
+ hasTargetOfType,
182
+ targetPath,
183
+ targetNameFromDir,
184
+ targetUrl,
185
+ targetPortOffset,
186
+ brandHost,
187
+ };
@@ -3,19 +3,24 @@
3
3
  *
4
4
  * The rule walker (runSchema) is electron-manager's proven validate-config
5
5
  * engine ported verbatim: required/type/match/enum semantics, where match +
6
- * enum only run on PRESENT values and a conditional `required` function
6
+ * enum (and itemEnum, the same check per member of an array value) only run on
7
+ * PRESENT values and a conditional `required` function
7
8
  * receives the full config. See schema.js for the rule format.
8
9
  *
9
10
  * validateConfig() layers on top of the walker:
10
11
  * - SHARED_SCHEMA always runs; TARGET_SCHEMAS[options.target] adds that
11
12
  * target's refinements against the same resolved top-level namespace
12
- * - `targets` sanity: unknown target names are errors (typo protection —
13
- * targets.website is a mistake, the canonical name is web) and each
14
- * present entry must be an object ({} = enabled with defaults) OR an
15
- * array of id'd instance entries (multi-instance targets: ids required,
16
- * dir-safe, unique per type; >1 backend instance is a WARNING)
13
+ * - `targets` sanity (#886): every key is a target NAME, so it must be a
14
+ * dir-safe slug, and every entry must be an object declaring a `type` the
15
+ * framework list knows (an ARRAY is the retired multi-instance form, whose
16
+ * replacement is a sibling key; >1 backend target is a WARNING)
17
+ * - the `repo` block (#883): one `provider` from REPO_PROVIDERS, and an `org`
18
+ * that is a real non-empty string whenever the block is there, because the
19
+ * org is the owner of every repo the brand's names derive under
20
+ * - `hosting` (#883) is a WEB target's key: a non-web target carrying it says
21
+ * something nothing serves, and its provider comes from HOSTING_PROVIDERS
17
22
  * - `cloud.config.authDomain`, when set, must be the brand's OWN host (the
18
- * resolved instance url, else brand.url): a firebaseapp.com value or a
23
+ * resolved target url, else brand.url): a firebaseapp.com value or a
19
24
  * mismatch is an error, and demo-* (emulator-only) projects are exempt
20
25
  * - retired keys are always errors (see retired-keys.js) — a name that was
21
26
  * renamed outright reads as nothing at all, so it fails loudly instead of
@@ -27,11 +32,12 @@
27
32
  * used to pass in silence
28
33
  */
29
34
 
30
- const { TARGETS, isCustomTargetEntry, SHARED_SCHEMA, TARGET_SCHEMAS } = require('./schema.js');
35
+ const { TARGETS, SHARED_SCHEMA, TARGET_SCHEMAS } = require('./schema.js');
31
36
  const { findSecretKeys } = require('./secrets.js');
32
37
  const { findRetiredKeys } = require('./retired-keys.js');
33
38
  const { isPlainObject } = require('./merge.js');
34
- const { INSTANCE_ID_PATTERN } = require('./instances.js');
39
+ const { TARGET_NAME_PATTERN, TARGET_TYPES } = require('./targets.js');
40
+ const { REPO_PROVIDERS, HOSTING_PROVIDERS } = require('./repo.js');
35
41
  const { isDemoProject } = require('./demo.js');
36
42
  const { isCountedFeature } = require('../account/features.js');
37
43
 
@@ -142,6 +148,18 @@ function runSchema(config, schema) {
142
148
  if (rule.enum && !rule.enum.includes(value)) {
143
149
  errors.push(`config.${rule.path} "${value}" is not allowed — must be one of [${rule.enum.join(', ')}]`);
144
150
  }
151
+
152
+ // ─── Item enum check (arrays) ───────────────────────────────────────────
153
+ // A LIST of allowed values (the extension's AMO categories): every member
154
+ // is named on its own line, so one bad slug in a good list is the one the
155
+ // error points at.
156
+ if (rule.itemEnum && Array.isArray(value)) {
157
+ for (const item of value) {
158
+ if (!rule.itemEnum.includes(item)) {
159
+ errors.push(`config.${rule.path} "${item}" is not allowed: must be one of [${rule.itemEnum.join(', ')}]`);
160
+ }
161
+ }
162
+ }
145
163
  }
146
164
 
147
165
  return errors;
@@ -149,13 +167,13 @@ function runSchema(config, schema) {
149
167
 
150
168
  /**
151
169
  * The host this (resolved) config's BRAND lives on, which is a brand-level
152
- * fact and never an instance one (#588, Ian 2026-09-01): one Firebase project,
153
- * one backend, one persona domain, shared by every instance a brand runs. So
154
- * `brand.url` answers first and the top-level `url` (the instance's own public
155
- * url, derived from its id or declared on its entry) is only the fallback for
170
+ * fact and never a per-target one (#588, Ian 2026-09-01): one Firebase project,
171
+ * one backend, one persona domain, shared by every target a brand runs. So
172
+ * `brand.url` answers first and the top-level `url` (the target's own public
173
+ * url, derived from its name or declared on its entry) is only the fallback for
156
174
  * a config that carries no brand.url at all. Reading them the other way round
157
- * made a `targets/website-admin` load fail its own brand's authDomain check
158
- * and seeded personas at a different domain than the backend's.
175
+ * made a `targets/admin` load fail its own brand's authDomain check and seeded
176
+ * personas at a different domain than the backend's.
159
177
  * @param {object} config - The resolved config object.
160
178
  * @returns {string} The lowercase hostname, or '' when no URL is known.
161
179
  */
@@ -173,6 +191,60 @@ function resolvedBrandHost(config) {
173
191
  }
174
192
  }
175
193
 
194
+ /**
195
+ * The repo block and the hosting key (#883).
196
+ *
197
+ * `repo` is `{ provider, org }` and nothing else: its PRESENCE enables the repo
198
+ * service, and every repo name derives from `<brand.id>-<role>`, so the org is
199
+ * the whole address. A block with no usable org is the failure this catches
200
+ * loudly, because every derivation would otherwise answer null and each reader
201
+ * would skip in its own quiet way.
202
+ *
203
+ * `hosting` says where a WEB target is served from. Only a web target has a
204
+ * built site to serve, so the key on any other type is a statement nothing
205
+ * reads. The targets map rides every resolved config, so this check answers the
206
+ * same on a raw config and on a target-resolved one.
207
+ *
208
+ * @param {object} config - The resolved config object.
209
+ * @returns {string[]} Errors; empty when the config passes.
210
+ */
211
+ function validateRepo(config) {
212
+ const errors = [];
213
+ const repo = config ? config.repo : undefined;
214
+
215
+ if (isPlainObject(repo)) {
216
+ if (repo.provider !== undefined && !REPO_PROVIDERS.includes(repo.provider)) {
217
+ errors.push(`config.repo.provider ${JSON.stringify(repo.provider)} is not a host OMEGA builds for: one of [${REPO_PROVIDERS.join(', ')}]`);
218
+ }
219
+
220
+ if (typeof repo.org !== 'string' || !repo.org.trim()) {
221
+ errors.push(
222
+ 'config.repo.org is required when the repo block is present: it is the org every repo the brand owns lives in '
223
+ + '(`<brand.id>-omega`, `<brand.id>-releases`, `<brand.id>-<web target>`), and no repo name is configurable anywhere',
224
+ );
225
+ }
226
+ }
227
+
228
+ const targets = config ? config.targets : undefined;
229
+ if (!isPlainObject(targets)) return errors;
230
+
231
+ Object.entries(targets).forEach(([name, entry]) => {
232
+ if (!isPlainObject(entry) || entry.hosting === undefined) return;
233
+
234
+ if (entry.type !== 'web') {
235
+ errors.push(`config.targets.${name}.hosting is a web-target key: a ${JSON.stringify(entry.type)} target has no built site to serve`);
236
+ return;
237
+ }
238
+
239
+ const provider = isPlainObject(entry.hosting) ? entry.hosting.provider : undefined;
240
+ if (provider !== undefined && !HOSTING_PROVIDERS.includes(provider)) {
241
+ errors.push(`config.targets.${name}.hosting.provider ${JSON.stringify(provider)} is not a host OMEGA builds for: one of [${HOSTING_PROVIDERS.join(', ')}]`);
242
+ }
243
+ });
244
+
245
+ return errors;
246
+ }
247
+
176
248
  /**
177
249
  * authDomain must be the brand's OWN host (cp268). Firebase's default
178
250
  * `<project>.firebaseapp.com` is a third-party host: under browser storage
@@ -410,64 +482,52 @@ function validateConfig(config, options) {
410
482
  // ─── targets sanity ────────────────────────────────────────────────────
411
483
  const targets = config ? config.targets : undefined;
412
484
  if (isPlainObject(targets)) {
485
+ const backends = [];
486
+
413
487
  Object.keys(targets).forEach((key) => {
414
488
  const entry = targets[key];
415
- const framework = TARGETS.includes(key);
416
-
417
- // A key no framework owns is legal ONLY as a declared custom target
418
- // (#603) — that declaration is what separates a deliberate Render API or
419
- // worker from a typo'd framework name. The framework keys are the
420
- // inverse: they may never claim the custom type, since their verbs come
421
- // from their framework, not from package.json scripts.
422
- if (!framework && !isCustomTargetEntry(entry)) {
423
- errors.push(
424
- `config.targets.${key} is not a known target — must be one of [${TARGETS.join(', ')}], `
425
- + `or declare \`type: 'custom'\` (every instance of the array form) to run through its own package.json scripts`,
426
- );
427
- return;
428
- }
429
- if (framework && isCustomTargetEntry(entry)) {
430
- errors.push(`config.targets.${key} is a framework target — it cannot declare \`type: 'custom'\``);
489
+
490
+ // The key is a NAME, and the name is the folder (targets/<name>), so it
491
+ // has to be a dir-safe slug (#886).
492
+ if (!TARGET_NAME_PATTERN.test(key)) {
493
+ errors.push(`config.targets.${key} is not a usable target name (lowercase, starts with a letter, alnum/-): the name IS the folder targets/${key}`);
431
494
  return;
432
495
  }
433
496
 
434
- // Multi-instance array form: every entry MUST carry a unique dir-safe
435
- // id (it names the targets/<canonical>-<id> dir). Backend stays single-
436
- // instance in practice — >1 is a warning, not an error (spec v1).
437
497
  if (Array.isArray(entry)) {
438
- if (entry.length === 0) {
439
- errors.push(`config.targets.${key} instance array must not be empty — use {} for a single default instance`);
440
- return;
441
- }
442
-
443
- const seen = new Set();
444
- entry.forEach((instance, index) => {
445
- if (!isPlainObject(instance)) {
446
- errors.push(`config.targets.${key}[${index}] must be an instance object — got ${Array.isArray(instance) ? 'array' : typeof instance}`);
447
- return;
448
- }
449
- if (typeof instance.id !== 'string' || !INSTANCE_ID_PATTERN.test(instance.id)) {
450
- errors.push(`config.targets.${key}[${index}] must carry a dir-safe id (lowercase, starts with a letter, alnum/-) — it names targets/<dir>-<id>`);
451
- return;
452
- }
453
- if (seen.has(instance.id)) {
454
- errors.push(`config.targets.${key} instance id "${instance.id}" is not unique — ids must be unique per target type`);
455
- }
456
- seen.add(instance.id);
457
- });
458
-
459
- if (key === 'backend' && entry.length > 1) {
460
- warnings.push(`config.targets.backend has ${entry.length} instances — multi-instance backend is unsupported for now (Cloud Functions = one project surface per brand)`);
461
- }
498
+ errors.push(`config.targets.${key} is an array; a second instance is a sibling key, see docs/shared/breaking-changes.md 2026-09-11`);
462
499
  return;
463
500
  }
464
501
 
465
502
  if (!isPlainObject(entry)) {
466
- errors.push(`config.targets.${key} must be an object ({} = enabled with defaults) or an array of id'd instances — got ${typeof entry}`);
503
+ errors.push(`config.targets.${key} must be an object declaring its type, got ${typeof entry}`);
504
+ return;
505
+ }
506
+
507
+ // The TYPE says which framework runs there, and nothing else does: a
508
+ // name no framework owns is a deliberate brand choice, never a typo to
509
+ // guess at, so the type is what every reader keys off.
510
+ if (!TARGET_TYPES.includes(entry.type)) {
511
+ errors.push(
512
+ `config.targets.${key} must declare \`type\`, one of [${TARGET_TYPES.join(', ')}]`
513
+ + `${entry.type === undefined ? '' : ` (got ${JSON.stringify(entry.type)})`}`,
514
+ );
515
+ return;
467
516
  }
517
+
518
+ if (entry.type === 'backend') backends.push(key);
468
519
  });
520
+
521
+ // Backend stays single-target in practice (one Cloud Functions surface per
522
+ // brand), so more than one is a warning, not an error.
523
+ if (backends.length > 1) {
524
+ warnings.push(`config.targets declares ${backends.length} backend targets (${backends.join(', ')}): multiple backends are unsupported for now (Cloud Functions = one project surface per brand)`);
525
+ }
469
526
  }
470
527
 
528
+ // ─── the repo block, and where a web target is hosted (#883) ───────────
529
+ validateRepo(config).forEach((error) => errors.push(error));
530
+
471
531
  // ─── authDomain is the brand's own host (cp268) ────────────────────────
472
532
  validateAuthDomain(config).forEach((error) => errors.push(error));
473
533