@omega.js/desktop 0.51.0 → 0.53.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 (289) hide show
  1. package/README.md +18 -13
  2. package/dist/assets/css/core/_initialize.scss +1 -1
  3. package/dist/assets/js/core/app-shell.js +14 -15
  4. package/dist/assets/themes/_template/_theme.js +6 -6
  5. package/dist/assets/themes/base/_includes/global/sections/account.html +4 -4
  6. package/dist/assets/themes/base/_includes/global/sections/app-sidebar.html +5 -5
  7. package/dist/assets/themes/base/_layouts/frontend/pages/account/index.html +3 -3
  8. package/dist/assets/themes/base/_layouts/frontend/pages/alternatives/index.html +1 -1
  9. package/dist/assets/themes/base/_layouts/frontend/pages/blog/tags/tag.html +1 -1
  10. package/dist/assets/themes/base/_layouts/frontend/pages/payment/confirmation.html +1 -1
  11. package/dist/assets/themes/base/_sections/marketing/newsletter-cta/section.js +2 -3
  12. package/dist/assets/themes/base/_sections/verts/unit/section.html +1 -1
  13. package/dist/assets/themes/base/_sections/verts/unit/section.js +3 -4
  14. package/dist/assets/themes/base/_theme.js +4 -3
  15. package/dist/assets/themes/bootstrap/_theme.js +2 -2
  16. package/dist/assets/themes/bootstrap/overrides/_links.scss +1 -1
  17. package/dist/assets/themes/classy/_theme.js +8 -6
  18. package/dist/assets/themes/classy/css/marketing/_sections.scss +1 -2
  19. package/dist/assets/themes/classy/js/hero-demo-form.js +3 -2
  20. package/dist/assets/themes/neobrutalism/_theme.js +5 -5
  21. package/dist/assets/themes/neobrutalism/js/pages/test/libraries/layers/index.js +1 -1
  22. package/dist/assets/themes/newsflash/_theme.js +6 -6
  23. package/dist/assets/themes/newsflash/js/pages/test/libraries/layers/index.js +1 -1
  24. package/dist/build.js +87 -111
  25. package/dist/cli.js +1 -1
  26. package/dist/commands/build.js +2 -2
  27. package/dist/commands/cdp/capture.js +2 -2
  28. package/dist/commands/cdp/quit.js +2 -2
  29. package/dist/commands/cdp/relaunch.js +2 -2
  30. package/dist/commands/cdp/theme.js +1 -1
  31. package/dist/commands/clean.js +2 -2
  32. package/dist/commands/deploy.js +4 -4
  33. package/dist/commands/finalize-release.js +4 -4
  34. package/dist/commands/install.js +3 -3
  35. package/dist/commands/launch.js +2 -2
  36. package/dist/commands/lib/deploy-precheck.js +3 -3
  37. package/dist/commands/lib/ensure-target.js +6 -6
  38. package/dist/commands/package.js +2 -2
  39. package/dist/commands/publish.js +2 -2
  40. package/dist/commands/release.js +3 -3
  41. package/dist/commands/runner.js +145 -48
  42. package/dist/commands/sign-windows.js +5 -5
  43. package/dist/commands/test.js +4 -4
  44. package/dist/commands/update.js +2 -2
  45. package/dist/commands/validate-certs.js +4 -4
  46. package/dist/commands/version.js +3 -3
  47. package/dist/defaults/.github/workflows/build.yml +2 -0
  48. package/dist/defaults/AGENTS.md +17 -8
  49. package/dist/defaults/config/omega.json5 +7 -7
  50. package/dist/defaults/hooks/build/post.js +1 -1
  51. package/dist/defaults/hooks/build/pre.js +1 -1
  52. package/dist/defaults/hooks/deploy/pre.js +1 -1
  53. package/dist/defaults/hooks/release/post.js +1 -1
  54. package/dist/defaults/hooks/release/pre.js +1 -1
  55. package/dist/defaults/src/assets/js/components/about/index.js +3 -5
  56. package/dist/defaults/src/assets/js/components/main/index.js +3 -5
  57. package/dist/defaults/src/assets/js/components/settings/index.js +3 -5
  58. package/dist/defaults/src/integrations/context-menu/index.js +2 -2
  59. package/dist/defaults/src/integrations/menu/index.js +3 -3
  60. package/dist/defaults/src/integrations/tray/index.js +3 -3
  61. package/dist/defaults/src/main.js +3 -5
  62. package/dist/defaults/src/preload.js +3 -5
  63. package/dist/defaults/test/README.md +2 -2
  64. package/dist/gulp/main.js +9 -10
  65. package/dist/gulp/tasks/audit.js +7 -7
  66. package/dist/gulp/tasks/build-config.js +8 -8
  67. package/dist/gulp/tasks/bundle.js +16 -16
  68. package/dist/gulp/tasks/defaults.js +3 -3
  69. package/dist/gulp/tasks/distribute.js +2 -2
  70. package/dist/gulp/tasks/html.js +9 -9
  71. package/dist/gulp/tasks/package-quick.js +3 -3
  72. package/dist/gulp/tasks/package.js +3 -3
  73. package/dist/gulp/tasks/release.js +3 -3
  74. package/dist/gulp/tasks/sass.js +6 -6
  75. package/dist/gulp/tasks/serve.js +4 -4
  76. package/dist/hooks/notarize-artifacts.js +1 -1
  77. package/dist/hooks/notarize.js +1 -1
  78. package/dist/index.js +5 -8
  79. package/dist/lib/_environment-mixin.js +50 -0
  80. package/dist/lib/_lifecycle-mixin.js +45 -0
  81. package/dist/lib/analytics.js +33 -35
  82. package/dist/lib/app-state.js +14 -14
  83. package/dist/lib/auth-flow.js +18 -18
  84. package/dist/lib/auth-persistence.js +12 -12
  85. package/dist/lib/auth.js +421 -0
  86. package/dist/lib/auto-updater.js +49 -49
  87. package/dist/lib/context-menu.js +13 -13
  88. package/dist/lib/context.js +19 -19
  89. package/dist/lib/deep-link.js +34 -34
  90. package/dist/lib/fontawesome.js +5 -5
  91. package/dist/lib/ipc.js +4 -4
  92. package/dist/lib/menu.js +25 -25
  93. package/dist/lib/protocol.js +5 -5
  94. package/dist/lib/remote-config.js +22 -22
  95. package/dist/lib/remote-scripts.js +21 -21
  96. package/dist/lib/restart-manager/index.js +28 -28
  97. package/dist/lib/sign-helpers/exec-with-limit.js +1 -1
  98. package/dist/lib/sign-helpers/sign-events.js +1 -1
  99. package/dist/lib/startup.js +18 -13
  100. package/dist/lib/storage.js +10 -10
  101. package/dist/lib/templating.js +16 -16
  102. package/dist/lib/theme.js +10 -10
  103. package/dist/lib/tray.js +27 -27
  104. package/dist/lib/usage.js +11 -11
  105. package/dist/lib/window-manager.js +26 -26
  106. package/dist/main.js +398 -483
  107. package/dist/preload.js +236 -176
  108. package/dist/renderer.js +417 -391
  109. package/dist/runner/job-started.js +1 -1
  110. package/dist/test/fixtures/consumer-app/config/omega.json5 +1 -1
  111. package/dist/test/fixtures/consumer-app/src/assets/js/components/main/index.js +8 -11
  112. package/dist/test/fixtures/consumer-app/src/main.js +5 -7
  113. package/dist/test/fixtures/consumer-app/src/preload.js +2 -2
  114. package/dist/test/harness/boot-entry.js +22 -20
  115. package/dist/test/harness/main-entry.js +31 -30
  116. package/dist/test/harness/renderer-entry.js +5 -5
  117. package/dist/test/harness/renderer-preload.js +137 -141
  118. package/dist/test/index.js +10 -10
  119. package/dist/test/runner.js +2 -2
  120. package/dist/test/runners/boot.js +7 -6
  121. package/dist/test/runners/electron.js +3 -2
  122. package/dist/test/runners/render-event.js +2 -2
  123. package/dist/test/suites/boot/consumer-app-boots.test.js +57 -26
  124. package/dist/test/suites/boot/restart-manager.test.js +2 -2
  125. package/dist/test/suites/boot/storage-bundled.test.js +5 -5
  126. package/dist/test/suites/boot/theme.test.js +13 -13
  127. package/dist/test/suites/build/audit.test.js +1 -1
  128. package/dist/test/suites/build/auth-persistence-resolve.test.js +6 -6
  129. package/dist/test/suites/build/boot-fixture.test.js +2 -2
  130. package/dist/test/suites/build/boot-runner-timeout.test.js +6 -5
  131. package/dist/test/suites/build/brand-scss.test.js +1 -1
  132. package/dist/test/suites/build/build-json-bake.test.js +1 -1
  133. package/dist/test/suites/build/cli.test.js +2 -2
  134. package/dist/test/suites/build/config-schema.test.js +5 -5
  135. package/dist/test/suites/build/defaults-scaffold.test.js +2 -2
  136. package/dist/test/suites/build/deploy-hook.test.js +3 -3
  137. package/dist/test/suites/build/ensure-target.test.js +2 -2
  138. package/dist/test/suites/build/env-delivery.test.js +2 -2
  139. package/dist/test/suites/build/esm-only-dependency.test.js +2 -2
  140. package/dist/test/suites/build/exports.test.js +9 -8
  141. package/dist/test/suites/build/get-config.test.js +4 -4
  142. package/dist/test/suites/build/manifest-deps.test.js +1 -1
  143. package/dist/test/suites/build/merge-line-files.test.js +1 -1
  144. package/dist/test/suites/build/omega-shell.test.js +34 -2
  145. package/dist/test/suites/build/omega.test.js +350 -0
  146. package/dist/test/suites/build/renderer-auth-bridge.test.js +211 -78
  147. package/dist/test/suites/build/runner.test.js +612 -5
  148. package/dist/test/suites/build/sentry.test.js +2 -2
  149. package/dist/test/suites/build/sign-windows-e2e.test.js +2 -2
  150. package/dist/test/suites/build/templating.test.js +3 -3
  151. package/dist/test/suites/build/test-stealth.test.js +7 -9
  152. package/dist/test/suites/build/url-helpers.test.js +55 -56
  153. package/dist/test/suites/build/validate-config.test.js +2 -2
  154. package/dist/test/suites/build/wave5-pins.test.js +2 -2
  155. package/dist/test/suites/main/analytics.test.js +59 -59
  156. package/dist/test/suites/main/app-state.test.js +66 -66
  157. package/dist/test/suites/main/auth-flow.test.js +41 -41
  158. package/dist/test/suites/main/auth-persistence.test.js +54 -43
  159. package/dist/test/suites/main/{client-bridge.integration.test.js → auth.integration.test.js} +10 -9
  160. package/dist/test/suites/main/auth.test.js +336 -0
  161. package/dist/test/suites/main/auto-updater.test.js +134 -134
  162. package/dist/test/suites/main/boot-sequence.test.js +37 -49
  163. package/dist/test/suites/main/context-menu.test.js +51 -50
  164. package/dist/test/suites/main/context.test.js +25 -25
  165. package/dist/test/suites/main/deep-link.test.js +74 -74
  166. package/dist/test/suites/main/fontawesome.test.js +27 -27
  167. package/dist/test/suites/main/ipc.test.js +35 -35
  168. package/dist/test/suites/main/menu.test.js +101 -100
  169. package/dist/test/suites/main/protocol.test.js +19 -19
  170. package/dist/test/suites/main/remote-config.test.js +63 -63
  171. package/dist/test/suites/main/remote-scripts.test.js +103 -103
  172. package/dist/test/suites/main/request.test.js +71 -0
  173. package/dist/test/suites/main/restart-manager.test.js +33 -33
  174. package/dist/test/suites/main/startup-paths-and-ua.test.js +4 -4
  175. package/dist/test/suites/main/startup.test.js +26 -26
  176. package/dist/test/suites/main/stealth-window.test.js +1 -1
  177. package/dist/test/suites/main/storage.test.js +25 -25
  178. package/dist/test/suites/main/theme.test.js +34 -34
  179. package/dist/test/suites/main/tray.test.js +79 -79
  180. package/dist/test/suites/main/url-helpers.test.js +133 -133
  181. package/dist/test/suites/main/usage.test.js +25 -25
  182. package/dist/test/suites/main/window-bounds.test.js +27 -27
  183. package/dist/test/suites/main/window-manager.test.js +44 -44
  184. package/dist/test/suites/renderer/analytics-bridge.test.js +5 -5
  185. package/dist/test/suites/renderer/cross-context-helpers.test.js +37 -31
  186. package/dist/test/suites/renderer/round-trip.test.js +3 -3
  187. package/dist/test/suites/renderer/tooltips.test.js +15 -15
  188. package/dist/test/suites/renderer/{window-em-surface.test.js → window-desktop-surface.test.js} +27 -7
  189. package/dist/test/utils/extended-mode-warning.js +1 -1
  190. package/dist/utils/boot-harness.js +56 -0
  191. package/dist/utils/mode-helpers.js +2 -15
  192. package/dist/utils/ship-keys.js +3 -3
  193. package/dist/utils/signing-status.js +51 -0
  194. package/dist/utils/test-events.js +7 -0
  195. package/dist/utils/test-stealth.js +6 -6
  196. package/dist/utils/url-helpers.js +52 -42
  197. package/dist/utils/user-agent.js +44 -0
  198. package/dist/vendor/account/engine.js +3 -3
  199. package/dist/vendor/account/index.js +14 -45
  200. package/dist/vendor/account/resolve-account.js +44 -0
  201. package/dist/vendor/account/schema.js +1 -1
  202. package/dist/vendor/account/user.js +99 -0
  203. package/dist/vendor/config/client-config.js +1 -1
  204. package/dist/vendor/config/environment.js +11 -30
  205. package/dist/vendor/config/index.js +13 -12
  206. package/dist/vendor/config/load.js +1 -2
  207. package/dist/vendor/config/platforms.js +1 -1
  208. package/dist/vendor/config/repo.js +24 -0
  209. package/dist/vendor/config/retired-keys.js +2 -2
  210. package/dist/vendor/config/schema.js +5 -8
  211. package/dist/vendor/config/site-global.js +2 -3
  212. package/dist/vendor/config/validate.js +1 -2
  213. package/dist/vendor/config/winback.js +1 -1
  214. package/dist/vendor/devkit/actions-secrets.js +1 -1
  215. package/dist/vendor/devkit/attach-log-file.js +1 -1
  216. package/dist/vendor/devkit/brand-version.js +162 -1
  217. package/dist/vendor/devkit/build-json.js +1 -1
  218. package/dist/vendor/devkit/cli-router.js +3 -4
  219. package/dist/vendor/devkit/defaults-engine.js +9 -11
  220. package/dist/vendor/devkit/deploy.js +64 -4
  221. package/dist/vendor/devkit/git-remote.js +78 -1
  222. package/dist/vendor/devkit/local.js +53 -3
  223. package/dist/vendor/devkit/lockfile.js +127 -0
  224. package/dist/vendor/devkit/merge-line-files.js +2 -3
  225. package/dist/vendor/devkit/pack-local.js +4 -7
  226. package/dist/vendor/devkit/preludes/origin-heal.js +35 -49
  227. package/dist/vendor/devkit/target-secrets.js +34 -45
  228. package/dist/vendor/devkit/test/runner-core.js +6 -6
  229. package/dist/vendor/monitoring/env.js +2 -2
  230. package/dist/vendor/monitoring/index.js +1 -1
  231. package/dist/vendor/monitoring/main.js +1 -1
  232. package/dist/vendor/monitoring/preload.js +1 -1
  233. package/dist/vendor/monitoring/renderer.js +1 -1
  234. package/docs/analytics.md +8 -8
  235. package/docs/app-state.md +19 -19
  236. package/docs/audit.md +4 -4
  237. package/docs/{client-bridge.md → auth.md} +88 -73
  238. package/docs/auto-updater.md +8 -8
  239. package/docs/boot-sequence.md +11 -6
  240. package/docs/build-system.md +1 -1
  241. package/docs/cdp-debugging.md +1 -1
  242. package/docs/common-mistakes.md +7 -7
  243. package/docs/config-schema.md +1 -1
  244. package/docs/context-menu.md +13 -13
  245. package/docs/context.md +11 -11
  246. package/docs/css.md +3 -9
  247. package/docs/deep-link.md +25 -25
  248. package/docs/environment-detection.md +16 -16
  249. package/docs/fontawesome.md +7 -5
  250. package/docs/hooks.md +8 -8
  251. package/docs/index.md +39 -28
  252. package/docs/ipc.md +10 -10
  253. package/docs/lib-modules.md +9 -9
  254. package/docs/logging.md +12 -14
  255. package/docs/menu.md +18 -18
  256. package/docs/releasing.md +4 -2
  257. package/docs/remote-config.md +9 -9
  258. package/docs/remote-scripts.md +12 -12
  259. package/docs/restart-manager.md +8 -8
  260. package/docs/runner.md +12 -10
  261. package/docs/sentry.md +4 -4
  262. package/docs/shared/analytics.md +1 -1
  263. package/docs/shared/brands.md +1 -1
  264. package/docs/shared/breaking-changes.md +79 -13
  265. package/docs/shared/config.md +24 -21
  266. package/docs/shared/deploys.md +30 -7
  267. package/docs/shared/local-dev.md +5 -3
  268. package/docs/shared/logging.md +1 -1
  269. package/docs/shared/monitoring.md +5 -5
  270. package/docs/shared/testing.md +2 -2
  271. package/docs/shared/theming.md +1 -1
  272. package/docs/shared/translation.md +19 -10
  273. package/docs/signing.md +1 -1
  274. package/docs/startup.md +16 -16
  275. package/docs/storage.md +11 -11
  276. package/docs/templating.md +3 -3
  277. package/docs/test-boot-layer.md +12 -12
  278. package/docs/test-framework.md +17 -17
  279. package/docs/themes.md +7 -7
  280. package/docs/tooltips.md +2 -2
  281. package/docs/tray.md +31 -31
  282. package/docs/usage.md +7 -7
  283. package/docs/verts.md +1 -1
  284. package/docs/windows.md +22 -22
  285. package/package.json +3 -4
  286. package/dist/lib/client-bridge.js +0 -374
  287. package/dist/lib/logger.js +0 -4
  288. package/dist/test/suites/build/manager.test.js +0 -213
  289. package/dist/test/suites/main/client-bridge.test.js +0 -262
@@ -21,12 +21,21 @@
21
21
  * mixed @omega.js FAMILY. Same verdict, different subject: that one is about
22
22
  * the framework packages a brand installed, this one about the brand's own
23
23
  * release number.
24
+ *
25
+ * Beside it, the LOCKFILE gate
26
+ * ([#938](https://github.com/Omega-JS-Stack/omega/issues/938)): the registry
27
+ * deploy lane ships the brand's own `package-lock.json`, and the runner's
28
+ * `npm ci` installs exactly what it says, so `assertBrandLockfile` refuses a
29
+ * lock that disagrees with the manifests' @omega.js specs before anything is
30
+ * pushed. `brandLockfileDrift` is the read both the gate and the lock
31
+ * regeneration (`lockfile.js`) share.
24
32
  */
25
33
  const path = require('path');
26
34
  const jetpack = require('fs-jetpack');
27
35
 
28
36
  const { resolveBrandRoot } = require('../config/index.js');
29
37
  const Logger = require('./logger.js');
38
+ const { isRegistrySpec, resolveWanted } = require('./update.js');
30
39
 
31
40
  const defaultLogger = new Logger('brand-version');
32
41
 
@@ -261,4 +270,156 @@ function assertBrandVersion({ dir }) {
261
270
  return { brandRoot, name, version };
262
271
  }
263
272
 
264
- module.exports = { readBrandVersion, listTargetPackages, bumpBrandVersion, assertBrandVersion, KINDS };
273
+ // The family's scope: the only dependencies the lockfile gate reads.
274
+ const SCOPE = '@omega.js/';
275
+
276
+ // A registry entry's `resolved` is a URL; a link target, a packed tarball or a
277
+ // directory is a path, which a runner has nothing at.
278
+ const REGISTRY_RESOLVED = /^https?:\/\//;
279
+
280
+ /**
281
+ * The lock entry npm would use for `name` as declared by the manifest at
282
+ * `where`: its own nested copy first, then the hoisted one at the root, the
283
+ * order node resolves in.
284
+ *
285
+ * @param {object} packages - The lock's `packages` map.
286
+ * @param {string} where - The manifest's dir relative to the root ('' for the root).
287
+ * @param {string} name - The dependency name.
288
+ * @returns {string|null} The lock key, or null when neither slot exists.
289
+ */
290
+ function lockKeyFor(packages, where, name) {
291
+ const keys = where ? [`${where}/node_modules/${name}`, `node_modules/${name}`] : [`node_modules/${name}`];
292
+ return keys.find((key) => packages[key]) || null;
293
+ }
294
+
295
+ /**
296
+ * Why a lock entry cannot satisfy a registry spec on a runner, or null when it
297
+ * does.
298
+ *
299
+ * @param {object|undefined} entry - The lock entry.
300
+ * @param {string} spec - The manifest's registry spec.
301
+ * @returns {string|null} The reason, worded to follow `<name> <spec> `.
302
+ */
303
+ function entryDrift(entry, spec) {
304
+ if (!entry) return 'is not in the lock';
305
+ if (entry.link) return `is locked as a link to ${entry.resolved}`;
306
+ if (entry.resolved && !REGISTRY_RESOLVED.test(entry.resolved)) return `is locked at the path ${entry.resolved}`;
307
+ if (resolveWanted(spec, [entry.version]) !== entry.version) return `is locked at ${entry.version || 'no version'}`;
308
+ return null;
309
+ }
310
+
311
+ /**
312
+ * The lock's STALE path entries: a key that names a folder under the root (a
313
+ * workspace, or a link target) rather than a `node_modules/` slot, where that
314
+ * folder is not on disk. A renamed target leaves one behind (`targets/website`
315
+ * after the move to `targets/web`), and npm keeps honoring the dependencies it
316
+ * declares on every lock-only run.
317
+ *
318
+ * @param {object} options
319
+ * @param {string} options.root - The install root the lock's keys are relative to.
320
+ * @param {object} options.packages - The lock's `packages` map.
321
+ * @returns {string[]} The stale keys, nested ones included.
322
+ */
323
+ function staleLockPaths({ root, packages }) {
324
+ return Object.keys(packages).filter((key) => key !== ''
325
+ && !key.split('/').includes('node_modules')
326
+ && !jetpack.exists(path.join(root, key)));
327
+ }
328
+
329
+ /**
330
+ * Every @omega.js registry spec in the brand's manifests (the root and each
331
+ * `targets/<name>/package.json`) that the brand's OWN lockfile does not
332
+ * satisfy. Read-only. `file:`/`link:`/git specs are the linked lane's, which
333
+ * regenerates its lock in the pack step, so only registry specs are read. A
334
+ * STALE lock entry (`staleLockPaths`) declaring an @omega.js spec that is NOT a
335
+ * registry one is drift too: npm would re-create the link it names on the next
336
+ * lock-only run.
337
+ *
338
+ * @param {object} options
339
+ * @param {string} options.root - The install root (a brand root, or a standalone target).
340
+ * @returns {{ lockPath: string, lock: object|null, drift: Array<{ manifest: string, name: string, spec: string, key: string|null, entry: object|undefined, reason: string }> }}
341
+ * `lock` is null when the root has none; `manifest` is the declaring
342
+ * manifest's dir relative to the root, `package.json` for the root itself.
343
+ */
344
+ function brandLockfileDrift({ root }) {
345
+ const lockPath = path.join(root, 'package-lock.json');
346
+ const lock = jetpack.exists(lockPath) === 'file' ? jetpack.read(lockPath, 'json') : null;
347
+ const packages = (lock && lock.packages) || {};
348
+
349
+ const manifests = [
350
+ { where: '', file: path.join(root, 'package.json') },
351
+ ...listTargetPackages({ brandRoot: root }).map((target) => ({ where: `targets/${target.name}`, file: target.manifest })),
352
+ ];
353
+
354
+ const drift = [];
355
+
356
+ for (const { where, file } of manifests) {
357
+ const pkg = jetpack.read(file, 'json') || {};
358
+ const declared = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
359
+
360
+ for (const [name, spec] of Object.entries(declared)) {
361
+ if (!name.startsWith(SCOPE) || !isRegistrySpec(spec)) continue;
362
+
363
+ const key = lockKeyFor(packages, where, name);
364
+ const entry = key ? packages[key] : undefined;
365
+ const reason = entryDrift(entry, spec);
366
+
367
+ if (reason) drift.push({ manifest: where || 'package.json', name, spec, key, entry, reason });
368
+ }
369
+ }
370
+
371
+ for (const stale of staleLockPaths({ root, packages })) {
372
+ const declared = { ...(packages[stale].dependencies || {}), ...(packages[stale].devDependencies || {}) };
373
+
374
+ for (const [name, spec] of Object.entries(declared)) {
375
+ if (!name.startsWith(SCOPE) || isRegistrySpec(spec)) continue;
376
+
377
+ // No key: nothing here is the dependency's own entry, and the pruning
378
+ // of stale paths (lockfile.js) is what removes this one.
379
+ drift.push({ manifest: stale, name, spec, key: null, entry: undefined, reason: `is declared by a stale lock entry (${stale} is not on disk)` });
380
+ }
381
+ }
382
+
383
+ return { lockPath, lock, drift };
384
+ }
385
+
386
+ /**
387
+ * The deploy gate on the registry lane: the brand's lockfile must exist and
388
+ * carry every @omega.js registry spec of its manifests as a registry entry at
389
+ * a version that satisfies it. Read-only, so a dry run runs it too. Agreement
390
+ * says nothing.
391
+ *
392
+ * @param {object} options
393
+ * @param {string} options.root - The brand root the lane snapshots.
394
+ * @returns {void}
395
+ * @throws {Error} A refusal naming each disagreeing entry and `omega i live`.
396
+ */
397
+ function assertBrandLockfile({ root }) {
398
+ const { lockPath, lock, drift } = brandLockfileDrift({ root });
399
+ const fix = ' fix: run `omega i live` in the brand, which regenerates the brand\'s own lockfile from its manifests (#938)';
400
+
401
+ if (!lock) {
402
+ throw refusal(
403
+ `${root} has no package-lock.json, and the deploy runner installs with \`npm ci\`, which needs one.\n${fix}`,
404
+ );
405
+ }
406
+
407
+ if (drift.length) {
408
+ throw refusal(
409
+ `${lockPath} disagrees with the manifests, and the deploy runner's \`npm ci\` installs the lock:\n`
410
+ + drift.map((item) => ` ${item.manifest}: ${item.name} ${item.spec} ${item.reason}\n`).join('')
411
+ + fix,
412
+ );
413
+ }
414
+ }
415
+
416
+ module.exports = {
417
+ readBrandVersion,
418
+ listTargetPackages,
419
+ bumpBrandVersion,
420
+ assertBrandVersion,
421
+ staleLockPaths,
422
+ brandLockfileDrift,
423
+ assertBrandLockfile,
424
+ KINDS,
425
+ };
@@ -23,7 +23,7 @@ const { clientConfig } = require('../config/index.js');
23
23
  // ([#894](https://github.com/Omega-JS-Stack/omega/issues/894)): the build's
24
24
  // verdict (`environment`), whether it was a BUILD rather than a dev/watch run,
25
25
  // and whether it publishes. A surface's own extra verdicts (desktop's
26
- // `server`) stay inside that surface's Manager and never reach the artifact.
26
+ // `server`) stay inside that surface's Omega instance and never reach the artifact.
27
27
  const MODE_KEYS = ['environment', 'build', 'publish'];
28
28
 
29
29
  /**
@@ -2,19 +2,18 @@
2
2
  //
3
3
  // Every framework bin parses argv (src/argv.js) and hands the result to a Main class
4
4
  // whose process() resolves a command name and runs commands/<name>.js from the
5
- // framework's dist. That router was copy-pasted per framework (UJM/BXM/EM,
6
- // drift-identical); this is the single implementation. @omega.js/backend's CLI is a different
5
+ // framework's dist; this is the single implementation. @omega.js/backend's CLI is a different
7
6
  // design (colon-style utility commands dispatching stateful command classes)
8
7
  // and does not use this.
9
8
  //
10
- // Resolution order (unchanged from the framework copies):
9
+ // Resolution order:
11
10
  // 1. Positional command (`mgr build`) — matched against command names and
12
11
  // their aliases; unknown names pass through as-is (missing file = error).
13
12
  // 2. Flag-style alias (`mgr --build`, `mgr -b`) — first alias-table hit wins.
14
13
  // 3. The default command (OMEGA convention: help — `setup` is retired,
15
14
  // #675, and every verb runs its own local scaffold).
16
15
  //
17
- // dotenv loading and any process-env fixups (e.g. EM's ELECTRON_RUN_AS_NODE
16
+ // dotenv loading and any process-env fixups (e.g. desktop's ELECTRON_RUN_AS_NODE
18
17
  // strip) stay in the framework's cli.js/bin — they're framework concerns, not
19
18
  // dispatch.
20
19
 
@@ -1,10 +1,8 @@
1
1
  // applyDefaults(config) — the shared OMEGA defaults-scaffolding engine.
2
2
  //
3
3
  // Every framework ships a defaults tree (src/defaults/ → dist/defaults/) that
4
- // setup copies into the consumer project. The copy rules lived as two divergent
5
- // implementations (BXM's gulp FILE_MAP task — the real one — and EM's plain-fs
6
- // copyDefaults; UJM carries a third copy of the BXM shape). This is the single
7
- // plain-fs engine, a normalized superset of both.
4
+ // setup copies into the consumer project. This is the single plain-fs engine
5
+ // every framework's scaffold runs.
8
6
  //
9
7
  // File-map rules: minimatch patterns (dot:true) matched against the RAW path
10
8
  // relative to defaultsDir, last-match-wins option merging. Per-rule options:
@@ -28,7 +26,7 @@
28
26
  // Engine built-ins (not expressed in the file map):
29
27
  // - `_.name` segments lose the leading `_` (dotfiles ship past npm's filter)
30
28
  // - non-final segments starting `_` (but not `_.`) are ARCHIVE dirs — skipped
31
- // (reference material that ships in the framework package, e.g. EM's `_mas/`)
29
+ // (reference material that ships in the framework package, e.g. desktop's `_mas/`)
32
30
  // - `.gitkeep` creates the destination directory, the file itself never copies
33
31
  // - `.DS_Store` never copies
34
32
  // - text writes are skipped when the destination is byte-identical (idempotent
@@ -46,7 +44,7 @@ const { minimatch } = require('minimatch');
46
44
  const { mergeLineBasedFiles, hasSectionMarkers, getCustomSection } = require('./merge-line-files');
47
45
 
48
46
  // Files with these extensions copy byte-for-byte and never go through
49
- // template/merge/transform (matches BXM's binary detection list).
47
+ // template/merge/transform.
50
48
  const BINARY_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg|ico|woff|woff2|ttf|otf|eot|pdf|zip|tar|gz|mp3|mp4|avi|mov)$/i;
51
49
 
52
50
  const RULE_DEFAULTS = {
@@ -70,7 +68,7 @@ const RULE_DEFAULTS = {
70
68
  * @param {Object<string, object>} [config.fileMap] - minimatch pattern → rule options (see header)
71
69
  * @param {string[]} [config.files] - Only process these absolute source paths (watch single-file mode)
72
70
  * @param {Function} [config.transform] - `(contents, item) => contents` global hook, run on every
73
- * non-binary file after per-rule processing (BXM's site-token templating pass)
71
+ * non-binary file after per-rule processing (e.g. extension's site-token templating pass)
74
72
  * @param {object} [config.logger] - `{ log, warn, error }` (defaults to console)
75
73
  * @returns {{ written: string[], merged: string[], skipped: string[], removed: string[] }} destination-relative paths per outcome
76
74
  */
@@ -202,7 +200,7 @@ function applyDefaults(config) {
202
200
  let didMerge = false;
203
201
 
204
202
  // Per-rule template render happens BEFORE merging, so merges compare
205
- // rendered framework defaults against the consumer's file (EM semantic).
203
+ // rendered framework defaults against the consumer's file.
206
204
  if (options.template) {
207
205
  contents = renderTemplate(contents, options.template);
208
206
  }
@@ -309,7 +307,7 @@ function strippedSegments(segments) {
309
307
  return segments.map((s) => (s.startsWith('_.') ? s.slice(1) : s));
310
308
  }
311
309
 
312
- // Last-match-wins option resolution across the file map (BXM's getFileOptions).
310
+ // Last-match-wins option resolution across the file map.
313
311
  function resolveOptions(relativePath, fileMap) {
314
312
  let options = { ...RULE_DEFAULTS };
315
313
 
@@ -323,7 +321,7 @@ function resolveOptions(relativePath, fileMap) {
323
321
  return options;
324
322
  }
325
323
 
326
- // Minimal `{{ key.path }}` substitution (EM's tolerant renderer — the standard).
324
+ // Minimal `{{ key.path }}` substitution, tolerant of unknown keys.
327
325
  // Unknown keys render as the original `{{ ... }}` string, so non-template content
328
326
  // with literal braces (e.g. GitHub Actions `${{ secrets.X }}`) survives.
329
327
  function renderTemplate(content, context) {
@@ -334,7 +332,7 @@ function renderTemplate(content, context) {
334
332
  });
335
333
  }
336
334
 
337
- // JSON5 defaults merge (BXM's mergeConfigs): framework defaults provide shape and
335
+ // JSON5 defaults merge: framework defaults provide shape and
338
336
  // order; the consumer's values win unless they're the literal string 'default';
339
337
  // consumer-only keys survive at every nesting level, EXCEPT one holding the
340
338
  // 'default' sentinel, which is the framework's own unset marker and drops (#926).
@@ -14,6 +14,7 @@ const { execSync, execFileSync } = require('node:child_process');
14
14
 
15
15
  const { findBrandRoot, discoverTargets, frameworkPackagesOf } = require('./local.js');
16
16
  const { stageLocalPackages, STAGING_DIR } = require('./pack-local.js');
17
+ const { assertBrandLockfile } = require('./brand-version.js');
17
18
  const {
18
19
  pushSnapshot,
19
20
  pushWorkflowFiles,
@@ -27,7 +28,8 @@ const {
27
28
  } = require('./deploy-snapshot.js');
28
29
  // The remote-url parse this module published before the boot prelude needed it
29
30
  // too (#890): it lives in the remote boundary now, re-exported here unchanged.
30
- const { parseRemoteUrl } = require('./git-remote.js');
31
+ // The origin gate (#934) lives there too, beside the one origin read.
32
+ const { parseRemoteUrl, assertOriginMatches } = require('./git-remote.js');
31
33
 
32
34
  const API_BASE = 'https://api.github.com';
33
35
 
@@ -213,6 +215,8 @@ function laneLabel(lane, snapshot) {
213
215
  // `deploy-precheck` uses): the tests drive the order without a push, a pack or
214
216
  // a network call, and the defaults are the real modules.
215
217
  const LANE_STEPS = {
218
+ origin: (options) => assertOriginMatches({ dir: options.root }),
219
+ lockfile: assertBrandLockfile,
216
220
  defaultBranch: defaultBranchOf,
217
221
  heal: healDefaultBranch,
218
222
  behind: assertNotBehind,
@@ -223,6 +227,42 @@ const LANE_STEPS = {
223
227
  wait: waitForWorkflow,
224
228
  };
225
229
 
230
+ /**
231
+ * The origin gate ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)):
232
+ * a brand whose `origin` names another repo than the source repo its config
233
+ * derives refuses, because every lane acts on the DERIVED repo (the snapshot
234
+ * push, the workflow compose, the dispatch). It runs on every lane, the
235
+ * dispatch-only one included: a brand outside git has no origin to disagree,
236
+ * which the gate answers as nothing to compare. Read-only, so the dry run runs
237
+ * it too.
238
+ *
239
+ * @param {object} lane - The resolved lane.
240
+ * @param {object} steps - The lane steps (`origin` is the gate).
241
+ * @returns {void}
242
+ * @throws {Error} The drift line, naming `repo.org`.
243
+ */
244
+ function checkLaneOrigin(lane, steps) {
245
+ steps.origin({ root: lane.brandRoot });
246
+ }
247
+
248
+ /**
249
+ * The lockfile gate, where the lane ships the brand's OWN lock
250
+ * ([#938](https://github.com/Omega-JS-Stack/omega/issues/938)): a registry
251
+ * (unlinked) snapshot. A linked lane regenerates its lock in the pack step, and
252
+ * a lane with no snapshot ships nothing, so neither has a lock of its own to
253
+ * check. Read-only, so the dry run runs it too.
254
+ *
255
+ * @param {object} lane - The resolved lane.
256
+ * @param {object} steps - The lane steps (`lockfile` is the gate).
257
+ * @returns {void}
258
+ * @throws {Error} The gate's refusal, naming `omega i live`.
259
+ */
260
+ function checkLaneLockfile(lane, steps) {
261
+ if (lane.mode === 'snapshot' && !lane.linked) {
262
+ steps.lockfile({ root: lane.brandRoot });
263
+ }
264
+ }
265
+
226
266
  /**
227
267
  * The DELIVERY half of a lane: how this brand's code reaches GitHub before the
228
268
  * dispatch that runs it. ONE implementation for its two callers
@@ -233,6 +273,13 @@ const LANE_STEPS = {
233
273
  *
234
274
  * What the lane delivers, in order
235
275
  * ([#915](https://github.com/Omega-JS-Stack/omega/issues/915)):
276
+ * 0. on EVERY lane, the ORIGIN gate
277
+ * ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)): a checkout
278
+ * whose `origin` is not the derived source repo refuses before this lane
279
+ * writes to that repo; then, on a registry (unlinked) lane, the LOCKFILE gate
280
+ * ([#938](https://github.com/Omega-JS-Stack/omega/issues/938)): the runner's
281
+ * `npm ci` installs the brand's lock as pushed, so one that disagrees with
282
+ * the manifests refuses here, before a branch is healed or a file written;
236
283
  * 1. the DEFAULT branch read once, and healed when published output has taken
237
284
  * it over ([#922](https://github.com/Omega-JS-Stack/omega/issues/922)): a
238
285
  * `gh-pages` default moves back to `main` here, before step 2 writes a
@@ -271,10 +318,14 @@ async function deliverLane(options) {
271
318
  const steps = { ...LANE_STEPS, ...(options.steps || {}) };
272
319
  const ref = options.ref || lane.ref;
273
320
 
321
+ checkLaneOrigin(lane, steps);
322
+
274
323
  if (lane.mode !== 'snapshot') {
275
324
  return { sha: null };
276
325
  }
277
326
 
327
+ checkLaneLockfile(lane, steps);
328
+
278
329
  // Read ONCE, used twice: the branch a behind checkout is measured against,
279
330
  // and the branch the composed workflows are pushed to. Null is a repo GitHub
280
331
  // does not have yet, and the snapshot push below is where that says itself.
@@ -428,9 +479,19 @@ async function deployViaDispatch(options) {
428
479
  inputs: options.inputs,
429
480
  });
430
481
 
482
+ const steps = { ...LANE_STEPS, ...(options.steps || {}) };
483
+
431
484
  if (options.dryRun) {
485
+ // The origin and lockfile gates only read, so a dry run refuses what the
486
+ // real run would (#934, #938), as the version gate already does.
487
+ if (lane) {
488
+ checkLaneOrigin(lane, steps);
489
+ checkLaneLockfile(lane, steps);
490
+ }
491
+
432
492
  // The plan IS the dry run, the lane included: what would happen, with
433
- // neither git nor the network touched. The workflow half of it is read off
493
+ // nothing written to git and the network untouched (the gates above only
494
+ // read). The workflow half of it is read off
434
495
  // DISK (the scaffold has already composed this run's files), so the preview
435
496
  // names them and the branch they would go to without asking GitHub which of
436
497
  // them differ (#915).
@@ -446,7 +507,6 @@ async function deployViaDispatch(options) {
446
507
  }
447
508
 
448
509
  const token = options.token || resolveToken({ env: options.env, execFn: options.execFn });
449
- const steps = { ...LANE_STEPS, ...(options.steps || {}) };
450
510
 
451
511
  if (lane && lane.mode === 'snapshot' && options.snapshot) {
452
512
  // The brand root pushed this run's snapshot ONCE, before it spawned a
@@ -539,7 +599,7 @@ function findLocalSpecs(options = {}) {
539
599
  * - NESTED: the brand root is not the toplevel of the git repo it sits in (a
540
600
  * brand inside this monorepo), so its git toplevel is somebody else's repo:
541
601
  * the behind check has nothing to compare, and the secrets publisher skips
542
- * its remote-mismatch guard.
602
+ * its origin gate.
543
603
  * - LINKED: the brand tree carries a `file:` @omega.js spec anywhere. A runner
544
604
  * can install none of those, so the packed tarballs have to travel with the
545
605
  * snapshot.
@@ -6,7 +6,10 @@
6
6
  * pack and snapshot modules: 40ms nothing on the CLI BOOT path can spend for
7
7
  * one regex. The boot prelude that heals a redirected `origin` needs the parse
8
8
  * plus the two git calls around it, so the three live here, in one place, and
9
- * `deploy.js` re-exports the parse it already published.
9
+ * `deploy.js` re-exports the parse it already published. The brand's own origin,
10
+ * read once for the prelude, the manage walk and the deploy alike, and the
11
+ * refusal the latter two raise when it disagrees with the config, live here too
12
+ * ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)).
10
13
  *
11
14
  * Transport: `git` through execFile with an ARGV ARRAY, never a shell string,
12
15
  * so a remote url can never inject a command. Every call names its directory
@@ -15,7 +18,9 @@
15
18
  * remote.
16
19
  */
17
20
 
21
+ const path = require('node:path');
18
22
  const { execFileSync } = require('node:child_process');
23
+ const jetpack = require('fs-jetpack');
19
24
 
20
25
  /**
21
26
  * Parse a git remote URL into { owner, repo }.
@@ -87,9 +92,81 @@ function setRemoteUrl(options) {
87
92
  execFn('git', args, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
88
93
  }
89
94
 
95
+ /**
96
+ * A brand's OWN `origin`, read and parsed: the one read the boot prelude, the
97
+ * manage walk and the deploy share
98
+ * ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)).
99
+ *
100
+ * The `.git` must sit AT the directory, answered by one stat before git is
101
+ * asked anything: a brand nested in another repo (a fixture brand inside this
102
+ * monorepo, a target checked out under someone else's tree) has no origin of
103
+ * its own, and reading the ENCLOSING repo's would compare, heal or refuse on a
104
+ * repo the brand is not. No origin, and an origin that is not a GitHub remote,
105
+ * are expected conditions (a checkout nobody has pushed yet, a brand hosted
106
+ * elsewhere), so each answers its reason rather than throwing.
107
+ *
108
+ * @param {object} options
109
+ * @param {string} options.dir - The brand root.
110
+ * @param {function} [options.execFn] - Injectable `(file, args, opts) => stdout` (tests).
111
+ * @returns {{ url: string, owner: string, repo: string, slug: string }|{ reason: 'no-git'|'no-origin'|'foreign-remote' }}
112
+ */
113
+ function readOrigin(options) {
114
+ if (!jetpack.exists(path.join(options.dir, '.git'))) return { reason: 'no-git' };
115
+
116
+ let url;
117
+ try {
118
+ url = remoteUrl({ dir: options.dir, execFn: options.execFn });
119
+ } catch (e) {
120
+ return { reason: 'no-origin' };
121
+ }
122
+
123
+ const parsed = parseRemoteUrl(url);
124
+ if (!parsed) return { reason: 'foreign-remote' };
125
+
126
+ return { url, owner: parsed.owner, repo: parsed.repo, slug: `${parsed.owner}/${parsed.repo}` };
127
+ }
128
+
129
+ /**
130
+ * Refuse when the brand's `origin` names another repo than the source repo its
131
+ * config derives ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)).
132
+ * Called by the verbs that ACT on the derived repo (the manage walk's repo
133
+ * service, the deploy lane), which would otherwise ensure or push to a repo the
134
+ * checkout does not point at. The boot prelude only states the same line: it
135
+ * runs before every verb, and a fatal boot would lock out the verbs that fix it.
136
+ *
137
+ * Nothing to compare is no refusal: a brand with no origin of its own (see
138
+ * `readOrigin`), and a config that derives no source repo.
139
+ *
140
+ * @param {object} options
141
+ * @param {string} options.dir - The brand root.
142
+ * @param {object} [options.config] - The composed config. Omitted, the brand's
143
+ * PRODUCTION config is loaded from `dir` (the one every deploy address reads,
144
+ * [#856](https://github.com/Omega-JS-Stack/omega/issues/856)), and only once
145
+ * an origin exists to compare it with.
146
+ * @param {function} [options.execFn] - Injectable git exec (tests).
147
+ * @returns {object} The `readOrigin` answer it compared (or had nothing to
148
+ * compare with), so a caller that treats a missing origin its own way reads
149
+ * the reason without a second read.
150
+ * @throws {Error} The drift line, from `@omega.js/config`'s `repoDrift`.
151
+ */
152
+ function assertOriginMatches(options) {
153
+ const origin = readOrigin(options);
154
+ if (!origin.slug) return origin;
155
+
156
+ const { loadConfig, repoDrift } = require('../config/index.js');
157
+ const config = options.config || loadConfig(options.dir, null, { environment: 'production' }).config;
158
+ const drift = repoDrift(origin.slug, config);
159
+
160
+ if (drift) throw new Error(drift);
161
+
162
+ return origin;
163
+ }
164
+
90
165
  module.exports = {
91
166
  parseRemoteUrl,
92
167
  retargetRemoteUrl,
93
168
  remoteUrl,
94
169
  setRemoteUrl,
170
+ readOrigin,
171
+ assertOriginMatches,
95
172
  };
@@ -261,6 +261,33 @@ function relativeSpecPath(fromDir, target) {
261
261
  return path.relative(fs.realpathSync(fromDir), fs.realpathSync(target)).split(path.sep).join('/');
262
262
  }
263
263
 
264
+ /**
265
+ * Regenerate the install root's OWN lockfile after a flip (#938): inside the
266
+ * omega monorepo the tree install climbs to the monorepo root and writes that
267
+ * lockfile, so the brand's would stay stale. Loaded here rather than at the top:
268
+ * this module runs at every CLI boot (freshnessBoot), and the lockfile read
269
+ * pulls in @omega.js/config, which the boot has no use for.
270
+ * @param {string} installRoot - The brand root (or standalone target).
271
+ * @param {object} [logger] - Logger with log (silent when omitted).
272
+ * @returns {Promise<void>}
273
+ */
274
+ function regenerateBrandLockfile(installRoot, logger) {
275
+ const { regenerateLockfile } = require('./lockfile.js');
276
+ return regenerateLockfile({ root: installRoot, log: logger ? (line) => logger.log(line) : undefined });
277
+ }
278
+
279
+ /**
280
+ * Whether the install root's lockfile is missing or disagrees with its
281
+ * manifests' @omega.js registry specs: the deploy gate's own read (#938).
282
+ * @param {string} installRoot - The brand root (or standalone target).
283
+ * @returns {boolean}
284
+ */
285
+ function brandLockfileStale(installRoot) {
286
+ const { brandLockfileDrift } = require('./brand-version.js');
287
+ const { lock, drift } = brandLockfileDrift({ root: installRoot });
288
+ return !lock || drift.length > 0;
289
+ }
290
+
264
291
  /**
265
292
  * file:-install a brand's @omega.js dependencies from the local monorepo.
266
293
  *
@@ -275,7 +302,8 @@ function relativeSpecPath(fromDir, target) {
275
302
  * targets degenerate to themselves.
276
303
  *
277
304
  * Idempotent: dependencies already resolving to the monorepo copy are
278
- * skipped, and when nothing needs linking no install runs.
305
+ * skipped, and when nothing needs linking no install runs. An install is
306
+ * followed by the brand's OWN lockfile regeneration (#938).
279
307
  * @param {object} options
280
308
  * @param {string} options.dir - Target directory to link from (any target in the brand).
281
309
  * @param {string} options.monorepoRoot - Monorepo root path.
@@ -347,6 +375,11 @@ async function linkLocalPackages(options) {
347
375
  logger && logger.warn(`install failed — restored ${manifestBackups.size} manifest(s) to their pre-link specs`);
348
376
  throw error;
349
377
  }
378
+
379
+ // Outside the rollback: the install succeeded, so the manifests and the
380
+ // tree already agree, and restoring the manifests now would split them.
381
+ // A failed regeneration puts the lock back itself and throws as what it is.
382
+ await regenerateBrandLockfile(installRoot, logger);
350
383
  }
351
384
 
352
385
  return actions;
@@ -364,7 +397,9 @@ async function linkLocalPackages(options) {
364
397
  * @omega.js/client in one brand. An explicit `range` still wins verbatim — the
365
398
  * caller owns that string. Idempotent: registry-spec'd entries are untouched;
366
399
  * nothing to flip → no install. Same transactional manifest restore as the
367
- * linker when the install fails.
400
+ * linker when the install fails. Either way the brand's OWN lockfile ends up
401
+ * agreeing with the manifests (#938): regenerated after the install, and
402
+ * regenerated alone when nothing flipped but the lock still disagrees.
368
403
  * @param {object} options
369
404
  * @param {string} options.dir - Any directory inside the brand.
370
405
  * @param {object} [options.logger] - Logger with log/warn (silent when omitted).
@@ -416,7 +451,11 @@ async function restoreRegistrySpecs(options) {
416
451
  }
417
452
  }
418
453
 
419
- if (installNeeded && !dryRun) {
454
+ if (dryRun) {
455
+ return actions;
456
+ }
457
+
458
+ if (installNeeded) {
420
459
  try {
421
460
  await safeInstall('npm install', { log: true, config: { cwd: installRoot } });
422
461
  } catch (error) {
@@ -426,6 +465,15 @@ async function restoreRegistrySpecs(options) {
426
465
  logger && logger.warn(`install failed — restored ${manifestBackups.size} manifest(s) to their file: specs`);
427
466
  throw error;
428
467
  }
468
+
469
+ // Outside the rollback, as in linkLocalPackages: the flipped manifests and
470
+ // the installed tree agree, so only the lock is left to heal.
471
+ await regenerateBrandLockfile(installRoot, logger);
472
+ } else if (brandLockfileStale(installRoot)) {
473
+ // Nothing to flip, but the lock still disagrees (the specs were flipped
474
+ // before the lock was ever regenerated): this verb is the fix the deploy's
475
+ // lockfile gate names, so it heals the lock here too (#938).
476
+ await regenerateBrandLockfile(installRoot, logger);
429
477
  }
430
478
 
431
479
  return actions;
@@ -1933,6 +1981,8 @@ module.exports = {
1933
1981
  findBrandRoot,
1934
1982
  discoverTargets,
1935
1983
  frameworkPackagesOf,
1984
+ resolvePackageRealDir,
1985
+ isLocalCheckout,
1936
1986
  linkLocalPackages,
1937
1987
  restoreRegistrySpecs,
1938
1988
  resolveLinkedMonorepo,