@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.
@@ -0,0 +1,127 @@
1
+ /**
2
+ * lockfile: regenerate an install root's OWN package-lock.json
3
+ * ([#938](https://github.com/Omega-JS-Stack/omega/issues/938)).
4
+ *
5
+ * ONE helper for its three callers: the pack step (`pack-local`, the staged
6
+ * shape), `omega i local` (`linkLocalPackages`) and `omega i live`
7
+ * (`restoreRegistrySpecs`).
8
+ *
9
+ * Two things a plain `npm install` gets wrong here, and this answers both:
10
+ * - `--prefix` makes npm treat `root` as the install root. Without it, npm in
11
+ * a directory that is itself a WORKSPACE of an outer root (every in-repo
12
+ * brand, under the omega monorepo's `brands/*`) climbs to that outer root
13
+ * and writes ITS lockfile, leaving the brand's own untouched.
14
+ * - npm KEEPS a locked link whose target's version satisfies a registry spec,
15
+ * so a lock from the local era (`link: true` into a checkout already at the
16
+ * published number) would survive the flip. Every @omega.js entry the
17
+ * deploy gate would refuse (`brandLockfileDrift`) is dropped first, with its
18
+ * link target, so npm resolves those afresh and keeps every other pin.
19
+ * - npm also honors a STALE path entry, a folder the lock names that is no
20
+ * longer on disk (a renamed target), and re-creates whatever link its
21
+ * dependencies declare. Those entries go first, with everything under them
22
+ * and every `node_modules/` link pointing into them.
23
+ *
24
+ * Transactional: a failed npm run writes the lock back byte for byte.
25
+ */
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const jetpack = require('fs-jetpack');
29
+
30
+ const { safeInstall } = require('./safe-install.js');
31
+ const { brandLockfileDrift, staleLockPaths } = require('./brand-version.js');
32
+
33
+ /** A path equal to base, or nested beneath it. */
34
+ const under = (value, base) => value === base || value.startsWith(`${base}/`);
35
+
36
+ /**
37
+ * Drop every stale path entry (`staleLockPaths`), everything nested under it,
38
+ * and every `node_modules/` entry whose `resolved` points into it.
39
+ *
40
+ * @param {object} lock - The parsed lock (mutated).
41
+ * @param {string} root - The install root the keys are relative to.
42
+ * @returns {string[]} The stale paths dropped (the outermost of each tree).
43
+ */
44
+ function dropStalePaths(lock, root) {
45
+ const stale = staleLockPaths({ root, packages: lock.packages });
46
+ const outermost = stale.filter((key) => !stale.some((other) => other !== key && under(key, other)));
47
+
48
+ for (const [key, entry] of Object.entries(lock.packages)) {
49
+ const pointsInto = entry && typeof entry.resolved === 'string' && outermost.some((base) => under(entry.resolved, base));
50
+ if (outermost.some((base) => under(key, base)) || pointsInto) delete lock.packages[key];
51
+ }
52
+
53
+ return outermost;
54
+ }
55
+
56
+ /**
57
+ * Drop every lock entry `drift` names, plus a dropped link's target entry and
58
+ * everything nested under it (the checkout's own dependencies).
59
+ *
60
+ * @param {object} lock - The parsed lock (mutated).
61
+ * @param {Array<{ key: string|null, entry: object|undefined }>} drift - What the gate refused.
62
+ * @returns {string[]} The names whose entries were dropped.
63
+ */
64
+ function dropDrift(lock, drift) {
65
+ const dropped = [];
66
+
67
+ for (const { name, key, entry } of drift) {
68
+ // An absent entry has nothing to drop (npm adds it from the manifest), and
69
+ // one inside a stale path is already gone with it.
70
+ if (!key || !lock.packages[key]) continue;
71
+
72
+ delete lock.packages[key];
73
+ dropped.push(name);
74
+
75
+ if (entry.link && entry.resolved) {
76
+ for (const other of Object.keys(lock.packages)) {
77
+ if (under(other, entry.resolved)) delete lock.packages[other];
78
+ }
79
+ }
80
+ }
81
+
82
+ return dropped;
83
+ }
84
+
85
+ /**
86
+ * Regenerate `root`'s package-lock.json from its manifests, lock-only: no
87
+ * scripts, no node_modules writes.
88
+ *
89
+ * @param {object} options
90
+ * @param {string} options.root - The install root whose lock is regenerated.
91
+ * @param {function} [options.log] - Line sink (silent when omitted).
92
+ * @returns {Promise<void>}
93
+ */
94
+ async function regenerateLockfile({ root, log }) {
95
+ const { lockPath, lock, drift } = brandLockfileDrift({ root });
96
+ const original = lock ? jetpack.read(lockPath) : null;
97
+
98
+ if (lock && lock.packages) {
99
+ const pruned = dropStalePaths(lock, root);
100
+ const dropped = dropDrift(lock, drift);
101
+
102
+ if (pruned.length || dropped.length) {
103
+ jetpack.write(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
104
+ }
105
+ if (log && pruned.length) log(`Pruned the lock entries of folders no longer on disk (${pruned.join(', ')}) so npm stops honoring what they declare`);
106
+ if (log && dropped.length) log(`Dropped the stale lock entries of ${dropped.join(', ')} so npm resolves them from the manifests`);
107
+ }
108
+
109
+ if (log) log(`Regenerating ${path.basename(root)}/package-lock.json...`);
110
+
111
+ // The REAL path: npm writes a link's `resolved` relative to the physical
112
+ // directory, so a prefix spelled through an alias (macOS /var for
113
+ // /private/var, a symlinked brand dir) reads those paths from the wrong place.
114
+ const prefix = fs.realpathSync(root);
115
+
116
+ try {
117
+ await safeInstall(
118
+ `npm install --package-lock-only --ignore-scripts --no-audit --no-fund --prefix "${prefix}"`,
119
+ { log: false, config: { cwd: prefix } },
120
+ );
121
+ } catch (error) {
122
+ if (original !== null) jetpack.write(lockPath, original);
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ module.exports = { regenerateLockfile };
@@ -47,6 +47,7 @@ const path = require('path');
47
47
  const jetpack = require('fs-jetpack');
48
48
  const powertools = require('node-powertools');
49
49
  const chalk = require('chalk').default;
50
+ const { regenerateLockfile } = require('./lockfile.js');
50
51
 
51
52
  // Constants
52
53
  const STAGING_DIR = 'omega_modules';
@@ -369,15 +370,11 @@ async function stageLocalPackages({ dir, log = () => {} }) {
369
370
  }
370
371
 
371
372
  // A remote install runs `npm ci`, so the uploaded lockfile must match the
372
- // staged shape. `--prefix` is what makes npm treat THIS dir as the install
373
- // root: run inside a workspace it would otherwise climb to the workspace
374
- // root and write that one's lockfile instead.
373
+ // staged shape, regenerated from scratch (the one helper keeps it THIS
374
+ // dir's lock, never an enclosing workspace root's).
375
375
  log(` ${chalk.dim('→')} Regenerating package-lock.json for the staged shape...`);
376
376
  jetpack.remove(lockPath);
377
- await powertools.execute(
378
- `npm install --package-lock-only --ignore-scripts --no-audit --no-fund --prefix "${root}"`,
379
- { log: false, config: { cwd: root } },
380
- );
377
+ await regenerateLockfile({ root });
381
378
  } catch (error) {
382
379
  // Loud and clean: the deploy stops here, and the tree goes back exactly as
383
380
  // it was rather than sitting half-staged.
@@ -32,21 +32,22 @@
32
32
  * - anything else: the remote is rewritten to the answered `full_name`,
33
33
  * keeping its own url form, and the heal is stated in one line.
34
34
  *
35
- * Then DRIFT, on whatever the answer was: `repo.org` stays the one typed value
36
- * in `config/omega.json5`, and an owner GitHub reports that differs from it is
37
- * stated in one line and nothing more. The prelude never writes config: which
38
- * of the two is wrong (the config, or where the repo lives) is a human's call,
39
- * and the manager's repo service only ensures repos UNDER `repo.org`.
35
+ * Then DRIFT, on whatever the answer was: the WHOLE slug GitHub answers against
36
+ * the source repo the config derives (`@omega.js/config`'s `repoDrift`, so a
37
+ * rename drifts as surely as a transfer,
38
+ * [#934](https://github.com/Omega-JS-Stack/omega/issues/934)), stated in one
39
+ * line and nothing more. Never fatal: this runs before every verb, and a fatal
40
+ * boot would lock out the verbs that fix the mismatch. The verbs that ACT on
41
+ * the derived repo (the manage walk, the deploy) refuse on the same line
42
+ * instead. The prelude never writes config: which of the two is wrong (the
43
+ * config, or where the repo lives) is a human's call.
40
44
  *
41
45
  * The WRITE is the one thing that fails loudly: a `git remote set-url` refused
42
46
  * after a `get-url` just answered is not an external condition, it is a broken
43
47
  * invariant, and the runner stops the boot on it.
44
48
  */
45
49
 
46
- const path = require('node:path');
47
- const jetpack = require('fs-jetpack');
48
-
49
- const { parseRemoteUrl, retargetRemoteUrl, remoteUrl, setRemoteUrl } = require('../git-remote.js');
50
+ const { readOrigin, retargetRemoteUrl, setRemoteUrl } = require('../git-remote.js');
50
51
  const github = require('../github-repo.js');
51
52
 
52
53
  // The one network read runs on EVERY verb boot, so a network that hangs (a
@@ -61,7 +62,7 @@ const GITHUB_READ_TIMEOUT_MS = 10000;
61
62
  * @param {function} [context.execFn] - Injectable git exec (tests).
62
63
  * @param {function} [context.resolveRepo] - Injectable `(owner, name) => repo|null` (tests).
63
64
  * @param {function} [context.log] - Injectable line printer (tests).
64
- * @returns {{ healed: boolean, reason?: string, from?: string, to?: string, drift?: { owner: string, org: string } }}
65
+ * @returns {{ healed: boolean, reason?: string, from?: string, to?: string, drift?: { origin: string, derived: string } }}
65
66
  */
66
67
  function run(context = {}) {
67
68
  const { brandRoot, execFn } = context;
@@ -72,21 +73,12 @@ function run(context = {}) {
72
73
 
73
74
  if (!brandRoot) return { healed: false, reason: 'no-brand' };
74
75
 
75
- // One stat, before anything else: most boots in this monorepo's own trees and
76
- // in every fixture brand end here.
77
- if (!jetpack.exists(path.join(brandRoot, '.git'))) return { healed: false, reason: 'no-git' };
76
+ // One stat before anything else (most boots in this monorepo's own trees and
77
+ // in every fixture brand end there), then the remote itself.
78
+ const current = readOrigin({ dir: brandRoot, execFn });
79
+ if (!current.slug) return { healed: false, reason: current.reason };
78
80
 
79
- let url;
80
- try {
81
- url = remoteUrl({ dir: brandRoot, execFn });
82
- } catch (e) {
83
- return { healed: false, reason: 'no-origin' };
84
- }
85
-
86
- const current = parseRemoteUrl(url);
87
- if (!current) return { healed: false, reason: 'foreign-remote' };
88
-
89
- const from = `${current.owner}/${current.repo}`;
81
+ const { url, slug: from } = current;
90
82
 
91
83
  // The ONE network read, on the slug the checkout carries: GitHub follows its
92
84
  // own redirect and names where that repo lives now.
@@ -109,44 +101,38 @@ function run(context = {}) {
109
101
  result = { healed: true, from, to };
110
102
  }
111
103
 
112
- const drift = configDrift(brandRoot, context.config, to.split('/')[0]);
104
+ const config = composedConfig(brandRoot, context.config);
105
+ const { repoDrift, sourceRepo } = require('../../config/index.js');
106
+ const drift = config ? repoDrift(to, config) : null;
107
+
113
108
  if (drift) {
114
- log(`omega: origin lives under ${drift.owner} but repo.org is ${drift.org}: fix repo.org in config/omega.json5 or move the repo`);
115
- result.drift = drift;
109
+ log(`omega: ${drift}`);
110
+ result.drift = { origin: to, derived: sourceRepo(config).slug };
116
111
  }
117
112
 
118
113
  return result;
119
114
  }
120
115
 
121
116
  /**
122
- * The gap between where the repo LIVES and the org the config types, or null
123
- * when there is none to state. The config is loaded here when the caller has
124
- * none, and an unloadable one answers "no drift": a config the boot cannot read
125
- * is the VERB's failure to report, in its own words, never a prelude's. A brand
126
- * declaring no `repo` block types no org, so there is nothing to disagree with.
117
+ * The composed config the drift is read against: the caller's when it has one,
118
+ * else loaded here, and an unloadable one answers null ("no drift"): a config
119
+ * the boot cannot read is the VERB's failure to report, in its own words, never
120
+ * a prelude's.
127
121
  *
128
122
  * @param {string} brandRoot - The brand root.
129
123
  * @param {object} [config] - The composed config, when the caller has it.
130
- * @param {string} owner - The owner GitHub resolved the repo under.
131
- * @returns {{ owner: string, org: string }|null}
124
+ * @returns {object|null}
132
125
  */
133
- function configDrift(brandRoot, config, owner) {
134
- const { loadConfig, repoBlock } = require('../../config/index.js');
126
+ function composedConfig(brandRoot, config) {
127
+ if (config) return config;
135
128
 
136
- let block;
137
- if (config) {
138
- block = repoBlock(config);
139
- } else {
140
- try {
141
- block = repoBlock(loadConfig(brandRoot).config);
142
- } catch (e) {
143
- return null;
144
- }
145
- }
146
-
147
- if (!block || block.org.toLowerCase() === owner.toLowerCase()) return null;
129
+ const { loadConfig } = require('../../config/index.js');
148
130
 
149
- return { owner, org: block.org };
131
+ try {
132
+ return loadConfig(brandRoot).config;
133
+ } catch (e) {
134
+ return null;
135
+ }
150
136
  }
151
137
 
152
138
  module.exports = {
@@ -31,7 +31,10 @@
31
31
  * config (`repo.org`, the SOURCE repo). An inferred git remote is not proof: a
32
32
  * target vendored into a framework/test monorepo, a cloned starter still
33
33
  * pointing at the template author, or any fork would publish this brand's
34
- * `.env` to a stranger's Actions.
34
+ * `.env` to a stranger's Actions. A checkout that could be the brand's own
35
+ * and whose origin names another repo REFUSES on `repoDrift`'s one line
36
+ * ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)), the
37
+ * same refusal the deploy lane and the manage walk make.
35
38
  * 4. PUBLISH — each collected key becomes a repo Actions secret via devkit's
36
39
  * `gh` boundary (values on stdin, never logged).
37
40
  *
@@ -70,11 +73,12 @@
70
73
  * sends nothing. The refusal still THROWS in a dry run: a plan that cannot be
71
74
  * made is loud.
72
75
  */
73
- const { composeTargetEnv, loadConfig } = require('../config/index.js');
76
+ const { composeTargetEnv, loadConfig, sourceRepo } = require('../config/index.js');
74
77
  const { publishSecretKeys } = require('../config/env-delivery.js');
75
78
  const { checkEnvRules } = require('../config/env-rules.js');
76
79
  const { publishActionsSecrets } = require('./actions-secrets.js');
77
- const { resolveRepo, resolveDeployLane } = require('./deploy.js');
80
+ const { assertOriginMatches } = require('./git-remote.js');
81
+ const { findBrandRoot } = require('./local.js');
78
82
  const { targetSeams } = require('./target-seams.js');
79
83
 
80
84
  /**
@@ -200,26 +204,18 @@ function missingRequiredSecrets(options) {
200
204
  }
201
205
 
202
206
  /**
203
- * The brand's SOURCE repo as `owner/name`, from the target's resolved config:
204
- * the one the workflows and their secrets live on, derived as
205
- * `<brand.id>-omega` under `repo.org` (#883). Null when the config declares
206
- * nothing usable or doesn't load.
207
+ * The target's resolved PRODUCTION config, like every other read this publish
208
+ * makes (#895): the repo a release's secrets belong to is the one the
209
+ * production config names. Null when it doesn't load.
207
210
  *
208
211
  * @param {object} options
209
212
  * @param {string} options.targetDir - The target root.
210
213
  * @param {string} options.target - Target name ('web', 'extension', …).
211
- * @returns {string|null}
214
+ * @returns {object|null}
212
215
  */
213
- function declaredBrandRepo(options) {
216
+ function productionConfig(options) {
214
217
  try {
215
- const { loadConfig, sourceRepo } = require('../config/index.js');
216
- // PRODUCTION, like every other read this publish makes (#895): the repo a
217
- // release's secrets belong to is the one the production config names.
218
- const { config } = loadConfig(options.targetDir, options.target, { environment: 'production' });
219
- if (!config) return null;
220
-
221
- const source = sourceRepo(config);
222
- return source ? source.slug : null;
218
+ return loadConfig(options.targetDir, options.target, { environment: 'production' }).config || null;
223
219
  } catch (e) {
224
220
  return null;
225
221
  }
@@ -241,7 +237,6 @@ function declaredBrandRepo(options) {
241
237
  * @param {Object<string, string>} [options.extraSecrets] - Override for the
242
238
  * target's already-valued keys, merged OVER the composed set (tests).
243
239
  * @param {function} [options.execFn] - Injectable `gh` exec (tests).
244
- * @param {function} [options.gitExecFn] - Injectable `git` exec for remote discovery (tests).
245
240
  * @returns {{ skipped: string }|{ planned: string[] }|{ published: string[], failed: Array<object> }}
246
241
  */
247
242
  function publishTargetSecrets(options) {
@@ -297,38 +292,32 @@ function publishTargetSecrets(options) {
297
292
  return { skipped: 'no-secrets' };
298
293
  }
299
294
 
300
- const declared = declaredBrandRepo({ targetDir, target });
301
- if (!declared) {
295
+ const config = productionConfig({ targetDir, target });
296
+ const source = config ? sourceRepo(config) : null;
297
+ if (!source) {
302
298
  logger.warn('Skipping secret publication: this brand names no GitHub repo in config (repo.org). Set it, then re-run `omega deploy`.');
303
299
  return { skipped: 'no-declared-repo' };
304
300
  }
305
301
 
306
- // A NESTED brand (its root is not the toplevel of the git repo it sits in)
307
- // has a remote that answers the ENCLOSING repo by construction, and its
308
- // deploy pushes a snapshot to the DECLARED repo regardless
309
- // ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)), so the
310
- // mismatch guard would only ever skip a publish that is correct. Every other
311
- // guard stays: what the guard protects against is a checkout that COULD have
312
- // been the brand's own and is not.
313
- const { nested } = resolveDeployLane({ dir: targetDir, execFn: options.gitExecFn });
314
- const repo = declared;
315
-
316
- if (!nested) {
317
- let remote;
318
- try {
319
- const { owner, repo: name } = resolveRepo({ cwd: targetDir, execFn: options.gitExecFn });
320
- remote = `${owner}/${name}`;
321
- } catch (e) {
322
- logger.warn(`Skipping secret publication — no GitHub remote here (${e.message})`);
323
- return { skipped: 'no-remote' };
324
- }
325
-
326
- if (declared.toLowerCase() !== remote.toLowerCase()) {
327
- logger.warn(`Skipping secret publication — the git remote here is ${remote}, but this brand's repo is ${declared}. Run \`omega deploy\` from the brand's own checkout.`);
328
- return { skipped: 'repo-mismatch' };
329
- }
302
+ // The ONE origin gate the deploy lane and the manage walk use
303
+ // ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)): the brand
304
+ // root's own origin (the local remote, which the boot prelude has already
305
+ // healed onto GitHub's redirect) must BE the derived source repo, or the
306
+ // publish REFUSES on the one drift line, because arming a repo that is not
307
+ // the derived one is the harm itself. No `.git` AT the brand root is nothing
308
+ // to compare: a NESTED brand's remote is the enclosing repo's by
309
+ // construction, and its deploy pushes to the derived repo regardless
310
+ // ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)). A checkout
311
+ // with no GitHub origin (nobody pushed it yet, or it is hosted elsewhere) has
312
+ // no repo to arm, so it skips.
313
+ const origin = assertOriginMatches({ dir: findBrandRoot(targetDir), config });
314
+ if (origin.reason === 'no-origin' || origin.reason === 'foreign-remote') {
315
+ logger.warn(`Skipping secret publication: no GitHub remote here (${origin.reason})`);
316
+ return { skipped: 'no-remote' };
330
317
  }
331
318
 
319
+ const repo = source.slug;
320
+
332
321
  // The PLAN, and nothing else (#895): what the composition answered, by NAME.
333
322
  // It runs after the refusal and the two guards above on purpose, so a dry run
334
323
  // is a real preview of whether the deploy would publish at all: every one of
@@ -347,4 +336,4 @@ function publishTargetSecrets(options) {
347
336
  return result;
348
337
  }
349
338
 
350
- module.exports = { collectTargetSecrets, declaredBrandRepo, missingRequiredSecrets, publishTargetSecrets };
339
+ module.exports = { collectTargetSecrets, missingRequiredSecrets, publishTargetSecrets };
package/docs/index.md CHANGED
@@ -305,7 +305,7 @@ API references for each subsystem live in `docs/`. **Whenever you make a behavio
305
305
  - [docs/installer-options.md](../docs/installer-options.md) — per-target installer config, defaults table
306
306
  - [docs/signing.md](../docs/signing.md) — code signing for macOS + Windows
307
307
  - [docs/releasing.md](../docs/releasing.md) — end-to-end release walkthrough
308
- - [docs/runner.md](../docs/runner.md): Windows EV-token signing runner. The listener runs with a private HOME (`%LOCALAPPDATA%\omega-runner\home`, holding a real `.gitconfig`), written at install and healed by `start`/`restart`, so a job's `actions/checkout` never copies the box's symlinked `~/.gitconfig` into a junction it cannot read ([#807](https://github.com/Omega-JS-Stack/omega/issues/807)). The same install writes the box's JOB GUARD ([#875](https://github.com/Omega-JS-Stack/omega/issues/875)): a job-started hook beside the runner, pointed at through each registration's `ACTIONS_RUNNER_HOOK_JOB_STARTED`, that fails any job whose event is not a dispatch or whose repository or actor is not on the box's own allow lists
308
+ - [docs/runner.md](../docs/runner.md): Windows EV-token signing runner. The listener runs with a private HOME (`%LOCALAPPDATA%\omega-runner\home`, holding a real `.gitconfig`), written at install and healed by `start`/`restart`, so a job's `actions/checkout` never copies the box's symlinked `~/.gitconfig` into a junction it cannot read ([#807](https://github.com/Omega-JS-Stack/omega/issues/807)). The same install writes the box's JOB GUARD ([#875](https://github.com/Omega-JS-Stack/omega/issues/875)): a job-started hook beside the runner, pointed at through each registration's `ACTIONS_RUNNER_HOOK_JOB_STARTED`, that fails any job whose event is not a dispatch or whose repository or actor is not on the box's own allow lists. `start` is the one command a box operator runs ([#937](https://github.com/Omega-JS-Stack/omega/issues/937)): it installs a bare box, refreshes a stale actions/runner download in place, and registers any admin org the box does not serve yet before bringing every org online
309
309
  - [docs/test-framework.md](../docs/test-framework.md) — writing tests, running them, layers
310
310
  - [docs/test-boot-layer.md](../docs/test-boot-layer.md) — the `boot` test layer: consumer end-to-end smoke + @omega.js/desktop's framework self-test from the repo via the bundled fixture (`src/test/fixtures/consumer-app/`) + `OMEGA_TEST_BOOT_PROJECT` (@omega.js/desktop's analog of @omega.js/backend/BXM/UJM `*_TEST_BOOT_PROJECT`)
311
311
  - [docs/build-system.md](../docs/build-system.md) — gulp, esbuild, electron-builder pipeline
package/docs/releasing.md CHANGED
@@ -92,7 +92,7 @@ Every verb runs `ensureTarget()` first ([#675](https://github.com/Omega-JS-Stack
92
92
  `npx omega deploy` adds the network half as a precheck (`--no-secrets` opts out):
93
93
 
94
94
  - Validates signing prereqs (warns if missing — non-fatal).
95
- - Pushes the composed `.env` → GitHub Actions secrets over the `gh` CLI (`gh auth login`; a CI run, an empty cascade, no remote, or a checkout that is not the brand's declared repo skips loudly).
95
+ - Pushes the composed `.env` → GitHub Actions secrets over the `gh` CLI (`gh auth login`; a CI run, an empty cascade, or no GitHub remote skips loudly, and a checkout whose `origin` is not the derived source repo refuses on the one drift line, `origin is <slug> but config derives <derived>: fix repo.org in config/omega.json5 or move the repo`, [#934](https://github.com/Omega-JS-Stack/omega/issues/934)).
96
96
 
97
97
  Now drop your cert files:
98
98
 
@@ -174,7 +174,9 @@ build needs setup; matrix over the resolved OSes: npm ci, then
174
174
  `npm run release:local` on mac and linux (sign, notarize, and
175
175
  electron-builder publishes the DRAFT release in the brand's releases
176
176
  repo), and `npm run package` on windows, whose unsigned output uploads
177
- as the `windows-unsigned` artifact
177
+ as the `windows-unsigned` artifact, a one-day intermediate that
178
+ windows-sign consumes in the same run (the releases repo is the
179
+ durable home)
178
180
  windows-strategy needs [setup, build]; reads platforms.windows.signing.strategy from config
179
181
  (only when windows is in the matrix)
180
182
  windows-sign the self-hosted EV-token box, hosted windows-latest for the cloud
package/docs/runner.md CHANGED
@@ -56,7 +56,7 @@ Then, in a normal (non-elevated) PowerShell:
56
56
 
57
57
  Then the orgs: a checkbox of every org your token administers, in alphabetical order. Ticked by default are the orgs this box already answered for — the saved `OMEGA_RUNNER_ORGS`, else the orgs it actually registered, else NOTHING. A token that administers 35 orgs must never register 35 runners on one Enter. Ticking nothing is refused too — a runner registered against no org is not a runner. Off a TTY the walk asks nothing: `config` says so and stops (edit the file instead), `install` refuses only when a required key is missing, and a blank org list still means every org the token administers, because CI has no keyboard.
58
58
 
59
- `start` is the exception, deliberately: it asks only for MISSING required keys, never the checkbox, and warns when the file's org list is not the one this install registered.
59
+ `start` is the exception, deliberately: it asks only for MISSING required keys, never the checkbox, and warns when the file's org list is not the one this install registered. On a bare box (nothing installed yet) `start` runs install itself, so there it is install's full walk, checkbox included.
60
60
 
61
61
  Orgs in `OMEGA_RUNNER_ORGS` you do not administer are named in a warning and skipped.
62
62
 
@@ -64,13 +64,13 @@ Orgs in `OMEGA_RUNNER_ORGS` you do not administer are named in a warning and ski
64
64
 
65
65
  ```powershell
66
66
  npx omega runner status # registered orgs, Startup shortcuts, live listeners, legacy leftovers
67
- npx omega runner start # bring EVERY registered org's runner up, detached (idempotent: an org already alive is skipped)
67
+ npx omega runner start # the one command: install a bare box, else heal HOME and the job guard, refresh a stale actions/runner in place,
68
+ # register any admin org not served yet, then bring EVERY registered org up, detached (an org already alive is skipped)
68
69
  npx omega runner restart # stop, wait for the listeners to go, then start
69
70
  npx omega runner stop # kill every Runner.Listener.exe under the runner home
70
- npx omega runner install # idempotent full setup — tears down first, so re-running is safe
71
+ npx omega runner install # the explicit clean rebuild: tears down first, so re-running is safe
71
72
  npx omega runner config # the same full walk install runs — every key, current values as the defaults, plus the orgs
72
73
  npx omega runner register-org <org># register one specific org
73
- npx omega runner self-update # npm i -g @omega.js/desktop@latest
74
74
  npx omega runner uninstall # remove everything, legacy services, tasks and the em-runner install included
75
75
  npx omega runner monitor # tail the signing event log
76
76
  ```
@@ -79,13 +79,13 @@ Notes worth knowing before you use them:
79
79
 
80
80
  - **`config` is `install`'s configuration step, alone.** Same walk, same defaults, same order — it just does not go on to register anything. A saved org your token no longer administers is named before the checkbox, since it cannot appear in it. Windows-only, and terminal-only (with nothing to ask with it stops and names the file). When the orgs you pick are not the ones this install registered, it says to re-run `install`.
81
81
  - **Every subcommand tees its output to `<runner home>\logs\runner.log`**, and so does `npx omega sign-windows` — including inside a `windows-sign` job, which is exactly when the box's own record is wanted (the log lives in the runner home, never in a workspace, so the usual "no logs in CI" rule does not apply to it). `runner status` prints the path on its own line. It APPENDS, because more than one process writes it: `start` returns as soon as the runners are spawned, and every `sign-windows` those listeners go on to run adds to the same file. Each run stamps its own `# omega log` header; nothing rotates it, so delete the file when you want a clean one. `uninstall` keeps `.env` and `logs\`, since the log it is writing while it runs is the trail of that uninstall.
82
- - **A refusal writes nothing.** Off Windows every subcommand but `self-update` and `monitor` stops at the platform check before the log file is opened, so running one on a Mac by accident leaves no `.gh-runners/` in the directory you were standing in.
82
+ - **A refusal writes nothing.** Off Windows every subcommand but `monitor` stops at the platform check before the log file is opened, so running one on a Mac by accident leaves no `.gh-runners/` in the directory you were standing in.
83
83
  - **The box verbs ignore the project's `.env` cascade.** `runner` and `sign-windows` read the shell and `<runner home>\.env`, nothing else, so running them from inside a brand folder can never hand a brand's `GH_TOKEN` to the box ([#337](https://github.com/Omega-JS-Stack/omega/issues/337)). Every other verb keeps the cascade.
84
- - **`start` brings up EVERY registered org, detached.** It walks the Startup shortcuts in order and spawns one hidden runner per org, so the terminal comes straight back and nothing is lost by not watching it: the listener's output is the JSONL log `monitor` tails. Running it again is safe, which is the point of it: an org whose listener is already alive is named with its PID and skipped, never duplicated and never a refusal. Exit 0 when every org ends up online, 1 when a spawn failed. A listener sitting in session 0 is skipped too, but loudly: it is alive and will fail every job it picks up, so the line names the session and points at `restart`. Killing a listener is `stop`'s job, never a start's.
84
+ - **`start` brings up EVERY registered org, detached.** It walks the Startup shortcuts in order and spawns one hidden runner per org, so the terminal comes straight back and nothing is lost by not watching it: the listener's output is the JSONL log `monitor` tails. Running it again is safe, which is the point of it: an org whose listener is already alive is named with its PID and skipped, never duplicated and never a refusal. Exit 0 when every org ends up online, 1 when a spawn or a registration failed. A listener sitting in session 0 is skipped too, but loudly: it is alive and will fail every job it picks up, so the line names the session and points at `restart`. Killing a listener is `stop`'s job, never a start's.
85
85
  - **`restart` is `stop` then `start`**, in that order, for the loop you would otherwise run by hand after a config change. It settles two things the two commands typed in sequence do not: the box config walk runs FIRST, so a box missing a required key is refused before anything is killed rather than left stopped, and each org's dir is polled (5 seconds) until no listener stands in it, because `taskkill` returns before the process is gone and a start that raced it would read the dying listener as "already running". A listener still standing after the wait is reported and that org is left alone, exit 1.
86
86
  - **`stop` leaves the Startup shortcuts in place**, so a logout/login brings the runner back. For a permanent stop, run `uninstall`.
87
- - **Every subcommand except `self-update` and `monitor` refuses on non-Windows.** `OMEGA_RUNNER_FORCE=1` overrides it, for framework tests only.
88
- - **A test process cannot touch a real box.** Every subcommand that changes the machine — `install`, `config`, `register-org`, `start`, `restart`, `stop`, `uninstall`, `self-update` — refuses, before the platform check and naming `OMEGA_TEST_RUNNER`, whenever the run is a test (`omega test` sets that variable; `OMEGA_TEST_MODE` counts too, the marker a sibling framework's runner or a consumer's own test script sets) and a home it could act on is not a scratch one, under a `.temp` directory or the OS temp dir. BOTH homes are checked, the passed one and the module-level `RUNNER_HOME`, because the box's `.env` was already read into the process from the latter when the command module was required; the refusal names whichever is real. The marker is read from the process environment only — an injected environment is a fixture for the config walk, never an answer to "am I a test". The Startup folder is the THIRD surface checked, because no home scopes it — `uninstall` sweeps every `omega-runner-*.cmd` in the folder whatever home it was given, and a case whose two homes were both scratch deleted the box's three real shortcuts. It has its own scratch seam, `OMEGA_RUNNER_STARTUP_DIR`, and a test run against the real folder refuses naming both. The surfaces that no path can redirect at all — the watcher service, the legacy logon tasks, the `actions.runner.*` services, and the legacy `em-runner` homes — are simply not swept under a test run; `uninstall` says so on one line instead. A test run also never kills a process whose ExecutablePath it cannot read: that path belongs to another account, and matching it scopes to no home at all, so `stop` and `uninstall` drop the clause instead of `taskkill /F`-ing a stranger's runner. And it never deregisters on its own: `deregisterOrgRunners` mints a real removal token and runs each org directory's `config.cmd`, neither of them home-scoped, so with a roster to remove a test run refuses, naming the seam, unless `exec` is injected and, while `GH_TOKEN` is set, `getRemoveToken` too. A framework suite that wants to drive these points `OMEGA_RUNNER_HOME` and `OMEGA_RUNNER_STARTUP_DIR` at scratches *before* the command module is required, since both are resolved once, at require time. `sign-windows` follows the same rule for its log: from a test run pointed at a real home it tees nowhere rather than write the box's own record. This exists because a suite once ran a real `install` on the box and registered 34 orgs.
87
+ - **Every subcommand except `monitor` refuses on non-Windows.** `OMEGA_RUNNER_FORCE=1` overrides it, for framework tests only.
88
+ - **A test process cannot touch a real box.** Every subcommand that changes the machine (`install`, `config`, `register-org`, `start`, `restart`, `stop`, `uninstall`) refuses, before the platform check and naming `OMEGA_TEST_RUNNER`, whenever the run is a test (`omega test` sets that variable; `OMEGA_TEST_MODE` counts too, the marker a sibling framework's runner or a consumer's own test script sets) and a home it could act on is not a scratch one, under a `.temp` directory or the OS temp dir. BOTH homes are checked, the passed one and the module-level `RUNNER_HOME`, because the box's `.env` was already read into the process from the latter when the command module was required; the refusal names whichever is real. The marker is read from the process environment only — an injected environment is a fixture for the config walk, never an answer to "am I a test". The Startup folder is the THIRD surface checked, because no home scopes it — `uninstall` sweeps every `omega-runner-*.cmd` in the folder whatever home it was given, and a case whose two homes were both scratch deleted the box's three real shortcuts. It has its own scratch seam, `OMEGA_RUNNER_STARTUP_DIR`, and a test run against the real folder refuses naming both. The surfaces that no path can redirect at all — the watcher service, the legacy logon tasks, the `actions.runner.*` services, and the legacy `em-runner` homes — are simply not swept under a test run; `uninstall` says so on one line instead. A test run also never kills a process whose ExecutablePath it cannot read: that path belongs to another account, and matching it scopes to no home at all, so `stop` and `uninstall` drop the clause instead of `taskkill /F`-ing a stranger's runner. And it never deregisters on its own: `deregisterOrgRunners` mints a real removal token and runs each org directory's `config.cmd`, neither of them home-scoped, so with a roster to remove a test run refuses, naming the seam, unless `exec` is injected and, while `GH_TOKEN` is set, `getRemoveToken` too. `start` and `restart` skip their org check the same way, on one line, unless both `_discoverOrgs` and `_registerOrg` are injected: registering acts on GitHub, never on a home. A framework suite that wants to drive these points `OMEGA_RUNNER_HOME` and `OMEGA_RUNNER_STARTUP_DIR` at scratches *before* the command module is required, since both are resolved once, at require time. `sign-windows` follows the same rule for its log: from a test run pointed at a real home it tees nowhere rather than write the box's own record. This exists because a suite once ran a real `install` on the box and registered 34 orgs.
89
89
  - **`uninstall` deregisters on the GitHub side first, and keeps whatever did not come off.** It walks every `actions-runner-<org>\` directory under the runner home and runs that directory's own `config.cmd remove` before anything is deleted. If a removal exits non-zero — or `GH_TOKEN` is not set, so no removal token can be minted — that runner is still registered, so its directory SURVIVES the uninstall and the summary names the org. Re-running `uninstall` retries it. Belt and braces: `register-org` also deletes every org-side runner starting with this host's prefix before it registers a new one.
90
90
 
91
91
  ## Upgrading from the electron-manager runner
@@ -102,7 +102,9 @@ So the upgrade on the box is the ordinary one: `npx omega runner install` with `
102
102
 
103
103
  ## Adding a new org
104
104
 
105
- Nothing happens automatically — no process is watching for new orgs. Run `npx omega runner install` again (it is idempotent and re-registers everything), or register just the one org:
105
+ No process watches for new orgs, but `start` checks every time it runs: `npx omega runner start` lists the orgs your `GH_TOKEN` administers, narrows them by `OMEGA_RUNNER_ORGS` when it is set, registers each one this box does not serve yet (Startup shortcut included), and brings it online with the rest. Orgs registered outside the list stay registered; `npx omega runner install` is the command that reshapes the box to the list exactly. An org check that cannot reach GitHub is one warn line, and `start` carries on and brings every registered org online.
106
+
107
+ To register one specific org alone:
106
108
 
107
109
  ```powershell
108
110
  npx omega runner register-org <org-name>
@@ -280,7 +282,7 @@ The SafeNet driver sometimes detaches after a major OS update. Open the SafeNet
280
282
 
281
283
  ## Upgrading the actions/runner binary
282
284
 
283
- `ACTIONS_RUNNER_VERSION` in `src/commands/runner.js` pins it. Bump the constant, ship a new `@omega.js/desktop`, then on the box: `npx omega runner self-update` followed by `npx omega runner install`. The install tears down the old tree and lays the new version down cleanly.
285
+ `ACTIONS_RUNNER_VERSION` in `src/commands/runner.js` pins it. Bump the constant, ship, then on the box run `npx omega runner start`. It sees that the version `config.json` recorded is not the pinned one, re-downloads `_template` in place, and copies it over each org dir whose listener is not running. `.runner`, `.credentials` and `.env` are not in the template, so every registration survives, and nothing is torn down. An org whose listener IS running has its binaries locked: it is skipped with one line naming `npx omega runner restart`, and the new version is recorded only once every org dir took it, so a `restart` (which stops first) finishes the job. `npx omega runner install` remains the clean rebuild when you want one.
284
286
 
285
287
  ## Related
286
288
 
@@ -16,7 +16,7 @@ Fixture for the AUTOMATED corpus/e2e suites: offline, `demo-*` Firebase, determi
16
16
 
17
17
  ### `brands/playground-omega` — "OMEGA Playground", the standing live test brand
18
18
 
19
- Renamed from omega-brand (Ian 2026-07-11, zero ambiguity). Born through the real wizard: id `playground` (shortened on 2026-09-10, [#808](https://github.com/Omega-JS-Stack/omega/issues/808), so the `<brand.id>-<role>` rule derives clean repo names off it), url playground.omegajs.dev, a SUBDOMAIN so derived surfaces never claim the real omegajs.dev. Its THREE repos all derive from that id, none of them typed ([#883](https://github.com/Omega-JS-Stack/omega/issues/883), all under the one `repo: { provider: 'github', org: 'Omega-JS-Stack' }` block): `playground-omega` (the PRIVATE source repo, and the folder name here, whose `main` every `omega deploy` snapshots this folder onto, [docs/shared/deploys.md](deploys.md)), `playground-releases` (public: the desktop installers, the extension zips and the autoupdater feed), and `playground-web` (public: the BUILT site alone, one force-orphan commit on `gh-pages`, served by Pages at playground.omegajs.dev, which is what lets the source repo stay private on a free org). The third, the private `playground-rehearsal` snapshot, retired with the rehearsal itself ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)). Points at the real-but-throwaway Firebase project `omegajs-playground` (ITW-org-owned since 2026-07-11; sanctioned for live proofs: Blaze it, break it, delete it; it is TEST INFRASTRUCTURE, never production).
19
+ Renamed from omega-brand (Ian 2026-07-11, zero ambiguity). Born through the real wizard: id `playground` (shortened on 2026-09-10, [#808](https://github.com/Omega-JS-Stack/omega/issues/808), so the `<brand.id>-<role>` rule derives clean repo names off it), url playground.omegajs.dev, a SUBDOMAIN so derived surfaces never claim the real omegajs.dev. Its THREE repos all derive from that id, none of them typed ([#883](https://github.com/Omega-JS-Stack/omega/issues/883), all under the one `repo: { provider: 'github', org: 'Omega-JS-Stack' }` block): `playground-omega` (the PUBLIC source repo since 2026-09-24, so its Actions runs never count against the org storage allowance; the folder name here, whose `main` every `omega deploy` snapshots this folder onto, [docs/shared/deploys.md](deploys.md)), `playground-releases` (public: the desktop installers, the extension zips and the autoupdater feed), and `playground-web` (public: the BUILT site alone, one force-orphan commit on `gh-pages`, served by Pages at playground.omegajs.dev, which is what let the source repo stay private on a free org before it went public). The third, the private `playground-rehearsal` snapshot, retired with the rehearsal itself ([#872](https://github.com/Omega-JS-Stack/omega/issues/872)). Points at the real-but-throwaway Firebase project `omegajs-playground` (ITW-org-owned since 2026-07-11; sanctioned for live proofs: Blaze it, break it, delete it; it is TEST INFRASTRUCTURE, never production).
20
20
 
21
21
  REBRANDED **OMEGA Playground** (Ian 2026-08-21, [#433](https://github.com/Omega-JS-Stack/omega/issues/433) — REVERSES the 2026-07-19 fiction ruling, whose invented brand read as a real product in the ad and analytics consoles). The doctrine now: the playground is openly the OMEGA test surface — "we can mess around here, nothing live actually matters" — so drift from the real omegajs.dev site reads as a demo doing its job, never as a broken copy. Infra identity unchanged (id, url, green accent, classy theme); name and voice are the only things that moved, and the catalog's display names stay placeholder demo copy. Cloud-side display names (the Meta pixel, the GA property + streams, the GCP project name) are the manager's follow-up.
22
22
 
@@ -1433,6 +1433,13 @@ of any kind exists: a repo name that must differ is a brand id that must differ.
1433
1433
  | Releases | `releasesRepo(config)` → `<repo.org>/<brand.id>-releases` | always public (the desktop updater polls it with no token) | every target's built artifacts: desktop installers, the extension's zips, tagged per target |
1434
1434
  | Website, one per GitHub-hosted web target | `websiteRepo(config, name)` → `<repo.org>/<brand.id>-<target name>` | private only when the brand is private AND the org's plan allows Pages from a private repo, else public, and the manage walk says which | the BUILT site only, one force-orphan commit on `gh-pages`, served by Pages at the target's url |
1435
1435
 
1436
+ **`repoDrift(originSlug, config)`** is the one comparison of a checkout's `origin` with
1437
+ the source repo ([#934](https://github.com/Omega-JS-Stack/omega/issues/934)): the whole
1438
+ slug, case-insensitive, null when they agree or the config derives no source repo, else
1439
+ the line `origin is <slug> but config derives <derived>: fix repo.org in
1440
+ config/omega.json5 or move the repo`. The boot prelude prints it; `omega manage` and
1441
+ `omega deploy` refuse with it ([deploys.md](deploys.md#the-origin-gate-934)).
1442
+
1436
1443
  **Visibility lives in the brand root's `package.json`**, never in omega.json5
1437
1444
  (`brandVisibility(brandRoot)`): `private: true` or the field ABSENT is a private brand
1438
1445
  (every brand monorepo is private by default, Ian 2026-09-11), and only a literal `false`