@omega.js/desktop 0.51.0 → 0.52.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.
@@ -43,7 +43,7 @@ const { resolveHook, loadHook } = require('./hooks.js');
43
43
  const { toSiteGlobal } = require('./site-global.js');
44
44
  const { PLATFORMS, FORMATS, enabledFormats, formatKeys, desktopProductName, sanitizeProductName, desktopArtifactName, desktopArtifactNames } = require('./platforms.js');
45
45
  const { resolveWinbackOffer, WINBACK_OFFER_DEFAULTS, WINBACK_DURATIONS } = require('./winback.js');
46
- const { REPO_PROVIDERS, HOSTING_PROVIDERS, repoBlock, sourceRepo, releasesRepo, websiteRepo, hostingProvider, pagesHost, brandVisibility } = require('./repo.js');
46
+ const { REPO_PROVIDERS, HOSTING_PROVIDERS, repoBlock, sourceRepo, repoDrift, releasesRepo, websiteRepo, hostingProvider, pagesHost, brandVisibility } = require('./repo.js');
47
47
  const { isDemoProject } = require('./demo.js');
48
48
  const { deriveBundleIdPrefix, composeBundleId } = require('./bundle-id.js');
49
49
  const { DEV_FACT_CHANNEL, devFactMissing } = require('./dev-facts.js');
@@ -222,6 +222,10 @@ module.exports = {
222
222
  // The SOURCE monorepo `<brand.id>-omega`: the dispatch, the repo secrets, the
223
223
  // scaffolded workflows and the CMS commits all address this one
224
224
  sourceRepo,
225
+ // Whether the repo `origin` names IS that source repo (#934): the one
226
+ // comparison and the one drift line, stated at boot and refused on by the
227
+ // verbs that act on the derived repo
228
+ repoDrift,
225
229
  // The brand's ONE public releases repo `<brand.id>-releases`, always public:
226
230
  // the one home every release reader takes its address from
227
231
  releasesRepo,
@@ -108,6 +108,29 @@ function sourceRepo(config) {
108
108
  return derivedRepo(config, 'omega');
109
109
  }
110
110
 
111
+ /**
112
+ * Whether the repo a brand's `origin` names IS the source repo its config
113
+ * derives ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)): the ONE
114
+ * comparison, and the ONE wording of its answer, for every reader that holds
115
+ * the two side by side. The boot prelude states it in one line; the manage walk
116
+ * and the deploy, which act ON the derived repo, refuse with it.
117
+ *
118
+ * The WHOLE slug is compared, case-insensitively (GitHub's own comparison), so
119
+ * a rename drifts as surely as a transfer: an owner-only compare let a renamed
120
+ * repo through while every reader kept deriving the old name.
121
+ *
122
+ * @param {string} originSlug - The origin's `Owner/name`.
123
+ * @param {object} config - Composed omega config.
124
+ * @returns {string|null} The drift line, or null when the two agree or the
125
+ * config derives no source repo (no org, or no brand id: nothing to compare).
126
+ */
127
+ function repoDrift(originSlug, config) {
128
+ const derived = sourceRepo(config);
129
+ if (!derived || derived.slug.toLowerCase() === originSlug.toLowerCase()) return null;
130
+
131
+ return `origin is ${originSlug} but config derives ${derived.slug}: fix repo.org in config/omega.json5 or move the repo`;
132
+ }
133
+
111
134
  /**
112
135
  * The brand's ONE public releases repo: `<brand.id>-releases` under the same
113
136
  * org, ALWAYS public, because the desktop updater polls it with no token
@@ -256,6 +279,7 @@ module.exports = {
256
279
  HOSTING_PROVIDERS,
257
280
  repoBlock,
258
281
  sourceRepo,
282
+ repoDrift,
259
283
  releasesRepo,
260
284
  websiteRepo,
261
285
  hostingProvider,
@@ -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
+ };
@@ -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;