@bongos/core 1.19.318 → 1.19.319

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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.318",
6
- "core_contract": "1.19.318",
7
- "source_commit": "01d0a25617f7ca9212b310552724d7eb8c183a32",
5
+ "core_version": "1.19.319",
6
+ "core_contract": "1.19.319",
7
+ "source_commit": "682a79a31b7882310cc854404a34a8743b0e3804",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-23T22:11:19.148Z",
9
+ "built_at": "2026-08-23T22:19:11.577Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 323,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1783,
20
- "tree_sha256": "24fa1a6a45d9a8ad89550dcdf06b82e5aeffa56776dcd6666a1be6524d9d4a6e",
20
+ "tree_sha256": "7c253e075a3fb04c94cc2fb0df42550178740faa03e84c8e9c1cb7675be2c23f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4642,12 +4642,12 @@
4642
4642
  {
4643
4643
  "path": "package-lock.json",
4644
4644
  "mode": "0000644",
4645
- "sha256": "509e4a76d5eb6f21fd8c926c6bf7ec9f1938d62ed4055558e2d3911858166b38"
4645
+ "sha256": "cb1d669bd4b49b50fc37854b48c9363f3b6a7bccf503cd3a1291dfa87ac072ac"
4646
4646
  },
4647
4647
  {
4648
4648
  "path": "package.json",
4649
4649
  "mode": "0000644",
4650
- "sha256": "5e3c53bb9627f4dcf747e5e7bc8fa7fb0e136554f04d96081137ca1bc05654ad"
4650
+ "sha256": "ca4620fbb1129ae165e0f7bfb9f4bc6d837c027a2d7326eec6d9418718bbf03c"
4651
4651
  },
4652
4652
  {
4653
4653
  "path": "public-docs/index.html",
@@ -5377,7 +5377,7 @@
5377
5377
  {
5378
5378
  "path": "scripts/gds/go-live.js",
5379
5379
  "mode": "0000644",
5380
- "sha256": "fa7803b178d64eda47d4f05032353582f7000359b13caea7d018112f03c9906a"
5380
+ "sha256": "1e14a452c0ae53384facd6a20526a90106cb479c414937ebb7fe180f11c5fa31"
5381
5381
  },
5382
5382
  {
5383
5383
  "path": "scripts/gds/goal.js",
@@ -5947,7 +5947,7 @@
5947
5947
  {
5948
5948
  "path": "scripts/gds/update-channel.js",
5949
5949
  "mode": "0000644",
5950
- "sha256": "e4ad34a4a98ad6e7327922c36a60eb54cbb507ca3e26a9ec793f5a9390eacd77"
5950
+ "sha256": "065095ccdcb104ede0312375484f62b23742fa1bc446f9749505c22b71babb5d"
5951
5951
  },
5952
5952
  {
5953
5953
  "path": "scripts/gds/upgrade.js",
@@ -6332,7 +6332,7 @@
6332
6332
  {
6333
6333
  "path": "src/module-api.js",
6334
6334
  "mode": "0000644",
6335
- "sha256": "af9d10cbaf1d6320df892f4d0e1403c0f5f1bb6e8c314695e89477690ba7fc85"
6335
+ "sha256": "c918c65478a9b35e7a10371f117e8a826c063f577331147740f9fa8ffa121161"
6336
6336
  },
6337
6337
  {
6338
6338
  "path": "src/module-loader/catalog.js",
@@ -7257,7 +7257,7 @@
7257
7257
  {
7258
7258
  "path": "tests/go_live.mjs",
7259
7259
  "mode": "0000644",
7260
- "sha256": "360712fee43b00b410e4b3b8ca27e801ae41966ac0c5ce0a4679fa7943aa554b"
7260
+ "sha256": "ce97c74d749ec3574c0683160b3ce5dad8a7af2dcf1ce53fd23e95f5e0ad480d"
7261
7261
  },
7262
7262
  {
7263
7263
  "path": "tests/goal_abuse_matrix.mjs",
@@ -8872,7 +8872,7 @@
8872
8872
  {
8873
8873
  "path": "tests/update_channel.mjs",
8874
8874
  "mode": "0000644",
8875
- "sha256": "4e082a36725d91f00d5fc0cc266d32bf607c144994ac88cb2abb3b49149632da"
8875
+ "sha256": "3b0912dcda6888b4daa2cd13bbef0cddddafc425c39a509dc4955ce77ddd720e"
8876
8876
  },
8877
8877
  {
8878
8878
  "path": "tests/upgrade.mjs",
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.318",
3
+ "version": "1.19.319",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.318",
9
+ "version": "1.19.319",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.318",
3
+ "version": "1.19.319",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -76,8 +76,9 @@ function isSafeToken(v) {
76
76
  // The fields a target must carry, and the ones it may. Unknown keys are reported
77
77
  // rather than ignored: a typo'd `healthURL` that silently disables the health gate
78
78
  // is precisely the class of quiet failure this pipeline keeps being bitten by.
79
- const REQUIRED_FIELDS = ['ssh', 'instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer'];
80
- const OPTIONAL_FIELDS = ['deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage'];
79
+ // `ssh` is NOT required: a target with no ssh host is LOCAL (the box driving itself).
80
+ const REQUIRED_FIELDS = ['instanceDir', 'service', 'healthUrl', 'versionUrl', 'deployTimer'];
81
+ const OPTIONAL_FIELDS = ['ssh', 'local', 'deployService', 'npmEnvFile', 'runAs', 'backupDb', 'backupDir', 'env', 'registryPackage'];
81
82
 
82
83
  function validateTarget(name, t) {
83
84
  const errors = [];
@@ -100,6 +101,10 @@ function validateTarget(name, t) {
100
101
  continue;
101
102
  }
102
103
  if (!known.has(k)) continue;
104
+ if (k === 'local') { // a flag, never interpolated into a command
105
+ if (typeof v !== 'boolean') errors.push(`target "${name}".local must be true or false`);
106
+ continue;
107
+ }
103
108
  if (!isSafeToken(v)) errors.push(`target "${name}".${k} contains characters that are unsafe in a shell (got ${JSON.stringify(v)})`);
104
109
  }
105
110
  return errors;
@@ -166,8 +171,32 @@ function selectTarget(config, name) {
166
171
  // the remote login shell). Values are validated above; this is the second layer.
167
172
  // ---------------------------------------------------------------------------
168
173
 
174
+ // Privilege preamble for any phase that touches systemd.
175
+ //
176
+ // Over ssh these phases run as ROOT, so `systemctl stop …` just works — which is what
177
+ // they originally assumed. Run LOCALLY by the nightly lane they run as the instance
178
+ // OWNER, where the same line fails with "Interactive authentication required" and the
179
+ // timer is never stopped: the pull-deploy `git reset --hard` then stays armed for the
180
+ // whole upgrade, which is the exact hazard the stop exists to remove. Deciding at RUN
181
+ // time keeps one script correct for both callers.
182
+ const SUDO_PREAMBLE = 'SUDO=""; [ "$(id -u)" -eq 0 ] || SUDO="sudo -n"';
183
+
184
+ // A target is LOCAL when it names no ssh host: the phases run through a local shell
185
+ // instead of a remote one. Everything else — the phases, the halts, the reporting —
186
+ // is byte-identical, which is the point: one deployment engine, reachable from a
187
+ // laptop (remote) or from the box itself (local, how the nightly lane uses it).
188
+ // Without this the unattended path would need its own second implementation, and the
189
+ // two would drift — which is exactly how the nightly deploy ended up with weaker
190
+ // checks than the manual one (task 1003219).
191
+ function isLocalTarget(target) {
192
+ return !target || !target.ssh;
193
+ }
194
+
169
195
  function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
170
- const res = run('ssh', ['-o', 'ConnectTimeout=20', '-o', 'BatchMode=yes', target.ssh, 'bash', '-s'], {
196
+ const [cmd, args] = isLocalTarget(target)
197
+ ? ['bash', ['-s']]
198
+ : ['ssh', ['-o', 'ConnectTimeout=20', '-o', 'BatchMode=yes', target.ssh, 'bash', '-s']];
199
+ const res = run(cmd, args, {
171
200
  input: script, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024,
172
201
  });
173
202
  if (!res || res.error) return { ok: false, status: null, stdout: '', stderr: res && res.error ? res.error.message : 'spawn failed' };
@@ -177,36 +206,47 @@ function remote(target, script, { run = spawnSync, timeoutMs = 900000 } = {}) {
177
206
  // `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
178
207
  // a string safe to embed because every component was allowlist-validated.
179
208
  //
180
- // `forward` names variables that exist in the calling root shell and must reach the
181
- // child — NPM_TOKEN above all. sudo's env_reset drops it, and an explicit `env K=V`
182
- // list that omits it drops it too, so `npm install` 404s the PRIVATE package and the
183
- // upgrade auto-rolls-back, which reads as "the release is broken" when it is only
184
- // "the token never arrived". Sourcing the env file in the OUTER shell is necessary
185
- // and NOT sufficient.
209
+ // A privileged child needs npm auth, and there is NO WAY to hand it a secret through
210
+ // sudo without logging it (task 1003218, corrected).
211
+ //
212
+ // `env NPM_TOKEN="$NPM_TOKEN" …` → sudo logs it in COMMAND=
213
+ // `sudo --preserve-env=NPM_TOKEN …` → sudo logs it in ENV=
186
214
  //
187
- // A FORWARDED VARIABLE IS A SECRET, SO IT MUST NOT ENTER argv (task 1003218).
188
- // `env K="$K"` keeps the value out of the script TEXT, but the shell EXPANDS it
189
- // before sudo runs — and sudo logs the full argv. That is how 26 lines of
190
- // `COMMAND=... NPM_TOKEN=npm_<real token> ...` ended up in the droplet's PERSISTENT
191
- // journal between 2026-08-17 and 2026-08-23. Passing by reference is NOT the same as
192
- // keeping a secret out of argv; only `--preserve-env` does that, handing the variable
193
- // over through the environment where nothing logs it. Verified side by side on the
194
- // box: both forms reach the child, only this one leaves no value in the journal.
215
+ // The first was the original bug; the second was my "fix", which only MOVED the value
216
+ // to a different field of the same log line. Both put a live credential in a
217
+ // persistent journal. What matters is that sudo logs its whole invocation, so the
218
+ // secret must never be part of it.
219
+ //
220
+ // So the child DERIVES the token itself, from a file it can already read. Only the
221
+ // `sed` appears in argv; the value never does. This is the shape docs/recipes/
222
+ // core-release-pipeline.md already documents as the safe one. The guard matters as
223
+ // much as the derivation: an EMPTY token silently sends npm to the public registry,
224
+ // which 404s the private package, which auto-rolls-back "cleanly" having taught you
225
+ // nothing — so an empty read aborts loudly instead of half-running.
226
+ //
227
+ // (The nightly lane never takes this path: it runs under systemd as the owner with an
228
+ // EnvironmentFile, no sudo, and leaks nothing. This is the manual/agent path only.)
229
+ const NPM_AUTH_PREAMBLE = [
230
+ 'export NPM_TOKEN="${NPM_TOKEN:-$(sed -n \'s/.*_authToken=//p\' "$HOME/.npmrc" 2>/dev/null | head -1)}"',
231
+ '[ -n "$NPM_TOKEN" ] || { echo "go-live: EMPTY NPM_TOKEN — npm would 404 the private core and roll back; aborting" >&2; exit 12; }',
232
+ ].join('; ');
233
+
234
+ // `sudo -u <user> -H env K=V … <cmd>` — the shape every runbook entry uses. Returns
235
+ // a string safe to embed because every component was allowlist-validated.
195
236
  //
196
- // The plain `env K=V` list is still used for NON-secret values (PGDATABASE, HOME) —
197
- // those are configuration, and seeing them in a log is useful rather than harmful.
198
- function asOwner(target, cmd, { forward = [] } = {}) {
237
+ // `needsNpm` wraps the command so it derives its own npm auth (see above) instead of
238
+ // having a secret handed across the privilege boundary. Only NON-secret configuration
239
+ // (PGDATABASE, HOME) travels in the plain `env K=V` list — seeing those in a log is
240
+ // useful rather than harmful.
241
+ function asOwner(target, cmd, { needsNpm = false } = {}) {
242
+ const inner = needsNpm ? `sh -c ${shq(`${NPM_AUTH_PREAMBLE}; ${cmd}`)}` : cmd;
199
243
  const envPairs = Object.entries(target.env || {}).map(([k, v]) => `${k}=${v}`);
200
- const preserve = forward.filter((k) => SAFE_ENV_KEY_RE.test(k)); // never interpolate an unvetted name
201
244
  if (!target.runAs) {
202
- // No privilege drop: the child already inherits our environment, so a forwarded
203
- // secret needs no mention at all.
204
245
  const joined = envPairs.join(' ');
205
- return `${joined ? `env ${joined} ` : ''}${cmd}`.trim();
246
+ return `${joined ? `env ${joined} ` : ''}${inner}`.trim();
206
247
  }
207
- const keep = preserve.length ? `--preserve-env=${preserve.join(',')} ` : '';
208
248
  const joined = [...envPairs, `HOME=/home/${target.runAs}`].join(' ');
209
- return `sudo ${keep}-u ${target.runAs} -H env ${joined} ${cmd}`.trim();
249
+ return `sudo -u ${target.runAs} -H env ${joined} ${inner}`.trim();
210
250
  }
211
251
 
212
252
  // POSIX single-quote a value for embedding in a shell command.
@@ -264,6 +304,12 @@ function parseMarkerHits(raw) {
264
304
  return Number(first);
265
305
  }
266
306
 
307
+ // Does this process sit inside a git repo that knows the given ref?
308
+ function gitRefExists(ref, { run = spawnSync } = {}) {
309
+ const r = run('git', ['rev-parse', '--verify', '--quiet', ref], { cwd: ROOT, encoding: 'utf8' });
310
+ return !!r && !r.error && r.status === 0;
311
+ }
312
+
267
313
  // Refresh origin/main before reading anything from it.
268
314
  //
269
315
  // deploy-divergence reads CORE_VERSION at the local `origin/main` REF, and a ref is
@@ -283,13 +329,6 @@ function refreshMain({ run = spawnSync } = {}) {
283
329
  return { ok: true };
284
330
  }
285
331
 
286
- // Which variables an npm-running command needs forwarded. Only when the target
287
- // declares an env file to source them from — otherwise `K="$K"` would expand to
288
- // empty and quietly do nothing.
289
- function npmForward(target) {
290
- return target.npmEnvFile ? ['NPM_TOKEN'] : [];
291
- }
292
-
293
332
  // Parse a `key=value` marker into its parts. Kept pure + exported: the marker is
294
333
  // the answer to the trap that the release current when your fix merges usually
295
334
  // does NOT contain your fix (ADR 0161 carriers bump on their own cadence).
@@ -435,13 +474,13 @@ function probeRemote(target, targetVersion, marker, opts = {}) {
435
474
  set -uo pipefail
436
475
  ${src}
437
476
  echo "##REGISTRY##"
438
- ${asOwner(target, `npm view ${pkg}@${targetVersion} version`, { forward: npmForward(target) })} 2>&1 | tail -1
477
+ ${asOwner(target, `npm view ${pkg}@${targetVersion} version`, { needsNpm: true })} 2>&1 | tail -1
439
478
  echo "##TREE##"
440
479
  ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain | head -20 )
441
480
  echo "##INSTALLED##"
442
481
  ${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
443
482
  ${marker ? `echo "##MARKER##"
444
- ${asOwner(target, `sh -c ${shq(markerProbe(pkg, targetVersion, marker))}`, { forward: npmForward(target) })}` : ''}
483
+ ${asOwner(target, `sh -c ${shq(markerProbe(pkg, targetVersion, marker))}`, { needsNpm: true })}` : ''}
445
484
  echo "##END##"
446
485
  `;
447
486
  const res = remote(target, script, opts);
@@ -479,7 +518,8 @@ function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
479
518
  : 'echo "(no backupDb configured — skipping)"';
480
519
  const phaseA = remote(target, `
481
520
  set -euo pipefail
482
- echo "##TIMER##"; systemctl stop ${target.deployTimer} && systemctl is-active ${target.deployTimer} || true
521
+ ${SUDO_PREAMBLE}
522
+ echo "##TIMER##"; $SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true
483
523
  echo "##BACKUP##"; ${backup}
484
524
  echo "##END##"
485
525
  `, opts);
@@ -499,7 +539,7 @@ echo "##END##"
499
539
  const phaseB = remote(target, `
500
540
  set -uo pipefail
501
541
  ${src}
502
- ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward: npmForward(target) })}
542
+ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { needsNpm: true })}
503
543
  `, opts);
504
544
  const rolledBack = /rolled back/i.test(phaseB.stdout + phaseB.stderr);
505
545
  steps.push({
@@ -510,7 +550,7 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward:
510
550
  if (!phaseB.ok || rolledBack) {
511
551
  // Restart the timer even on failure — leaving it stopped silently disables
512
552
  // the instance's pull-deploy until someone notices.
513
- remote(target, `systemctl start ${target.deployTimer} || true`, opts);
553
+ remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
514
554
  return { ok: false, steps, from, to: targetVersion, targetName, failedAt: 'UPGRADE', rolledBack, raw: phaseB };
515
555
  }
516
556
 
@@ -519,10 +559,11 @@ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`, { forward:
519
559
  // not evidence; 127.0.0.1 is.
520
560
  const phaseC = remote(target, `
521
561
  set -uo pipefail
522
- echo "##TIMERSTART##"; systemctl start ${target.deployTimer}; systemctl is-active ${target.deployTimer}
562
+ ${SUDO_PREAMBLE}
563
+ echo "##TIMERSTART##"; $SUDO systemctl start ${target.deployTimer}; $SUDO systemctl is-active ${target.deployTimer}
523
564
  echo "##PIN##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git status --porcelain -- package.json package-lock.json )
524
565
  echo "##HEAD##"; ( cd ${target.instanceDir} && ${target.runAs ? `sudo -u ${target.runAs} -H ` : ''}git log -1 --format='%h %s' )
525
- ${target.deployService ? `echo "##TICK##"; systemctl start ${target.deployService} || true; journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
566
+ ${target.deployService ? `echo "##TICK##"; $SUDO systemctl start ${target.deployService} || true; $SUDO journalctl -u ${target.deployService} -n 20 --no-pager | tail -8` : ''}
526
567
  echo "##VERSION##"; curl -s --max-time 20 ${target.versionUrl} | head -c 400
527
568
  echo ""
528
569
  echo "##INSTALLED##"; ${asOwner(target, `node -e "console.log(require('${target.instanceDir}/node_modules/${pkg}/package.json').version)"`)} 2>&1 | tail -1
@@ -563,6 +604,8 @@ go-live — move an enrolled instance's core pin, as one command.
563
604
  node scripts/gds/go-live.js --config <path> default: ${DEFAULT_CONFIG}
564
605
  node scripts/gds/go-live.js --allow-unreleased downgrade UNRELEASED_ON_MAIN to a warning
565
606
  node scripts/gds/go-live.js --no-fetch skip the origin/main refresh (default: fetch)
607
+ node scripts/gds/go-live.js --target-json '{…}' pass a target inline instead of a config file
608
+ node scripts/gds/go-live.js --no-divergence skip the git-derived checks (needs --to)
566
609
  node scripts/gds/go-live.js --json machine-readable
567
610
 
568
611
  Host topology is NOT baked into the core. ${DEFAULT_CONFIG} (host-owned, not published):
@@ -578,6 +621,24 @@ async function main() {
578
621
  const apply = hasFlag('--apply');
579
622
  const allowUnreleased = hasFlag('--allow-unreleased');
580
623
 
624
+ // An inline target lets a CALLER drive this without inventing a second host-owned
625
+ // config file beside the one it already has. The nightly lane already holds a roster
626
+ // entry; making it also maintain config/go-live.json would be two sources of truth
627
+ // for one box, which is how they drift.
628
+ const inline = arg('--target-json');
629
+ if (inline) {
630
+ let parsed;
631
+ try { parsed = JSON.parse(inline); }
632
+ catch (e) { console.error(` go-live: --target-json is not valid JSON — ${e.message}`); return 2; }
633
+ const errors = validateTarget('inline', parsed);
634
+ if (errors.length) {
635
+ if (json) { console.log(JSON.stringify({ ok: false, halt: 'CONFIG_INVALID', detail: errors.join('; ') }, null, 2)); return 1; }
636
+ console.error(`\n go-live: CONFIG_INVALID — ${errors.join('; ')}\n`);
637
+ return 1;
638
+ }
639
+ return runGoLive({ target: parsed, targetName: arg('--target') || 'inline', configPath: '--target-json', json, apply, allowUnreleased });
640
+ }
641
+
581
642
  const cfg = loadConfig({ configPath: arg('--config') });
582
643
  if (cfg.error) {
583
644
  if (json) { console.log(JSON.stringify({ ok: false, halt: cfg.error, path: cfg.path, detail: cfg.detail }, null, 2)); return 1; }
@@ -595,7 +656,10 @@ async function main() {
595
656
  return 1;
596
657
  }
597
658
  const { target, name: targetName } = sel;
659
+ return runGoLive({ target, targetName, configPath: cfg.path, json, apply, allowUnreleased });
660
+ }
598
661
 
662
+ async function runGoLive({ target, targetName, configPath, json, apply, allowUnreleased }) {
599
663
  const marker = parseMarker(arg('--verify-marker'));
600
664
  if (marker && marker.error) { console.error(` go-live: ${marker.error}`); return 2; }
601
665
 
@@ -603,18 +667,40 @@ async function main() {
603
667
  let targetVersion = arg('--to');
604
668
  if (targetVersion && !SEMVER_RE.test(targetVersion)) { console.error(` go-live: --to "${targetVersion}" is not a version`); return 2; }
605
669
 
606
- const fetched = hasFlag('--no-fetch') ? { ok: false, skipped: true } : refreshMain();
607
- const div = await divergence.check({ base: arg('--url') });
670
+ // Is there a core GIT CHECKOUT to reason about at all?
671
+ //
672
+ // Every git-derived signal — the target read from origin/main, the UNRELEASED halt,
673
+ // the dirty-checkout halt — presumes this process is running inside a core repo.
674
+ // The nightly lane is NOT: it runs on the box, against an installed package, and it
675
+ // resolves its target from the REGISTRY under a channel policy instead. Running the
676
+ // git checks there would halt every sweep on a condition that does not apply.
677
+ //
678
+ // So the git legs are SKIPPED only when both are true: there is no resolvable
679
+ // origin/main, AND the caller named the version explicitly. A missing checkout with
680
+ // no --to is still an error — silently inventing a target is exactly the class of
681
+ // guess this script exists to refuse.
682
+ // "Am I in a SOURCE checkout?" is `.git` AT OUR ROOT — not "is there a git repo
683
+ // somewhere above me". The installed core package lives inside the INSTANCE's repo
684
+ // and ships its own src/module-api.js, so a naive `git rev-parse HEAD` succeeds
685
+ // there and CORE_VERSION resolves — against the instance repo's main, which is a
686
+ // meaningless answer delivered confidently. That is the failure this check avoids.
687
+ const hasCheckout = fs.existsSync(path.join(ROOT, '.git'));
688
+ const gitless = (!hasCheckout || hasFlag('--no-divergence')) && !!targetVersion;
689
+
690
+ const fetched = (gitless || hasFlag('--no-fetch')) ? { ok: false, skipped: true } : refreshMain();
691
+ const div = gitless ? null : await divergence.check({ base: arg('--url') });
608
692
  if (!targetVersion) targetVersion = div && div.onMain;
609
- if (targetVersion && !SEMVER_RE.test(targetVersion)) {
610
- console.error(` go-live: could not resolve a target version (read ${JSON.stringify(targetVersion)} from main)`);
693
+ if (!targetVersion || !SEMVER_RE.test(targetVersion)) {
694
+ console.error(` go-live: could not resolve a target version${hasCheckout ? ` (read ${JSON.stringify(targetVersion)} from main)` : ' — this is not a core source checkout, so pass --to <version>'}`);
611
695
  return 1;
612
696
  }
613
697
 
614
- const dirtyRes = spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
698
+ const dirtyRes = gitless ? { status: 1 } : spawnSync('git', ['status', '--porcelain'], { cwd: ROOT, encoding: 'utf8' });
615
699
  const dirtyCheckout = dirtyRes.status === 0 && (dirtyRes.stdout || '').trim() ? (dirtyRes.stdout || '').trim().split('\n').slice(0, 5).join(', ') : null;
616
700
 
617
- const plan = planGoLive({ div, target, targetVersion, dirtyCheckout, allowUnreleased });
701
+ const plan = gitless
702
+ ? { halts: [], notes: [{ code: 'NO_CORE_CHECKOUT', message: `no core git checkout here — target ${targetVersion} was given explicitly, so the git-derived checks (unreleased-on-main, dirty tree) are skipped` }], from: null, targetVersion, target }
703
+ : planGoLive({ div, target, targetVersion, dirtyCheckout, allowUnreleased });
618
704
  plan.marker = marker;
619
705
  if (!fetched.ok && !fetched.skipped) {
620
706
  // Say so rather than silently planning off a ref of unknown age.
@@ -626,6 +712,14 @@ async function main() {
626
712
  if (!plan.halts.length) {
627
713
  const probe = probeRemote(target, targetVersion, marker);
628
714
  plan.registry = probe.registry;
715
+ // With no source checkout there is no git-derived `from`; the installed version on
716
+ // the box IS the truth, and the probe just read it.
717
+ if (gitless && probe.installed) {
718
+ plan.from = probe.installed;
719
+ if (probe.installed === targetVersion) {
720
+ plan.halts.push({ code: 'ALREADY_LIVE', message: HALTS.ALREADY_LIVE, detail: `installed and target are both ${targetVersion}` });
721
+ }
722
+ }
629
723
  if (!probe.reachable) {
630
724
  plan.halts.push({ code: 'BOX_UNREACHABLE', message: `could not reach ${target.ssh}`, detail: (probe.raw.stderr || '').trim().split('\n').pop() });
631
725
  } else {
@@ -656,7 +750,7 @@ async function main() {
656
750
 
657
751
  if (!apply || plan.halts.length) {
658
752
  if (json) { console.log(JSON.stringify({ ok: !plan.halts.length, mode: apply ? 'halted' : 'plan', targetName, ...plan }, null, 2)); }
659
- else { console.log(renderPlan(plan, { targetName, configPath: cfg.path })); }
753
+ else { console.log(renderPlan(plan, { targetName, configPath })); }
660
754
  return plan.halts.length ? 1 : 0;
661
755
  }
662
756
 
@@ -675,6 +769,6 @@ if (require.main === module) {
675
769
 
676
770
  module.exports = {
677
771
  loadConfig, selectTarget, validateTarget, planGoLive, parseMarker, parseMarkerHits,
678
- renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, npmForward, refreshMain,
772
+ renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, NPM_AUTH_PREAMBLE, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
679
773
  CONFIG_TEMPLATE, HALTS, SEMVER_RE,
680
774
  };
@@ -159,6 +159,18 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
159
159
  service: r.service ? String(r.service) : null,
160
160
  healthUrl: r.healthUrl ? String(r.healthUrl) : null,
161
161
  channel: normalizeChannel(r.channel == null ? defaultChannel : r.channel),
162
+ // Deploy topology (task 1003219). Optional, and only meaningful together: with
163
+ // versionUrl + deployTimer present the sweep can hand the bump to go-live.js and
164
+ // inherit its backup, pull-deploy-timer guard and INDEPENDENT box-side version
165
+ // read-back. Absent, the sweep falls back to calling `bongos upgrade` directly.
166
+ // Carried through here because a normalizer that silently drops a field makes an
167
+ // entry look enrolled while the capability it names never engages.
168
+ versionUrl: r.versionUrl ? String(r.versionUrl) : null,
169
+ deployTimer: r.deployTimer ? String(r.deployTimer) : null,
170
+ deployService: r.deployService ? String(r.deployService) : null,
171
+ backupDb: r.backupDb ? String(r.backupDb) : null,
172
+ backupDir: r.backupDir ? String(r.backupDir) : null,
173
+ registryPackage: r.registryPackage ? String(r.registryPackage) : null,
162
174
  // Per-instance env merged into the `bongos upgrade` child (e.g. PGDATABASE so the migrate step
163
175
  // hits THIS instance's DB, not the control plane's — see the live-op learnings for demo). Only
164
176
  // string values are kept; anything else is ignored.
package/src/module-api.js CHANGED
@@ -1063,8 +1063,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1063
1063
  // landed since 1.19.316 with no explicit bump. run 32669571610. (task 1002620)
1064
1064
  // 1.19.318 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1065
1065
  // landed since 1.19.317 with no explicit bump. run 32669798745. (task 1002620)
1066
+ // 1.19.319 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1067
+ // landed since 1.19.318 with no explicit bump. run 32670195088. (task 1002620)
1066
1068
  // ---------------------------------------------------------------------------
1067
- const CORE_VERSION = '1.19.318'; // CI auto-patch carrier (ADR 0161); see changelog above
1069
+ const CORE_VERSION = '1.19.319'; // CI auto-patch carrier (ADR 0161); see changelog above
1068
1070
 
1069
1071
  // A namespaced logger so a module's log lines are attributable + consistent.
1070
1072
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/go_live.mjs CHANGED
@@ -296,48 +296,40 @@ test("'could not check' is a distinct halt from 'absent'", () => {
296
296
  // `env K=V` list that omits it drops it too. This cost a real failed go-live.
297
297
  // ---------------------------------------------------------------------------
298
298
 
299
- test('a forwarded secret NEVER enters argv — it is handed over via --preserve-env', () => {
300
- const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { forward: ['NPM_TOKEN'] });
301
- assert.match(cmd, /--preserve-env=NPM_TOKEN/);
302
- // THE REGRESSION (task 1003218). `env K="$K"` kept the value out of the script TEXT,
303
- // but the shell expands it before sudo runs and sudo LOGS THE FULL argv — which put
304
- // the real token into the droplet's persistent journal 26 times. So the command must
305
- // not mention the variable as an assignment at all, by reference or otherwise.
306
- assert.ok(!/NPM_TOKEN=/.test(cmd.replace('--preserve-env=NPM_TOKEN', '')),
307
- 'a secret must never appear as an argv assignment — sudo logs argv');
308
- // Non-secret config is still passed plainly; seeing PGDATABASE in a log is useful.
299
+ test('a privileged npm command DERIVES its token — no secret in argv at all', () => {
300
+ const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { needsNpm: true });
301
+ // THE REGRESSION, TWICE (task 1003218). `env NPM_TOKEN="$NPM_TOKEN"` put the value in
302
+ // sudo's COMMAND= field. `--preserve-env=NPM_TOKEN` moved it to sudo's ENV= field —
303
+ // a different field of the SAME log line, which is not a fix. sudo logs its whole
304
+ // invocation, so the only safe answer is that the secret is never part of it.
305
+ assert.ok(!/NPM_TOKEN=npm_/.test(cmd), 'no token VALUE may appear');
306
+ assert.ok(!/--preserve-env/.test(cmd), '--preserve-env logs the value in ENV=');
307
+ assert.ok(!/env [^;]*NPM_TOKEN="\$NPM_TOKEN"/.test(cmd), 'no by-reference assignment in argv either');
308
+ // What SHOULD be there: the child deriving it, and refusing to run on an empty read.
309
+ assert.match(cmd, /_authToken/);
310
+ assert.match(cmd, /EMPTY NPM_TOKEN/);
311
+ // Non-secret config still travels plainly.
309
312
  assert.match(cmd, /PGDATABASE=cb/);
310
313
  });
311
314
 
312
- test('forwarding is only wired when the target names an env file to source from', () => {
313
- // Otherwise K="$K" expands to empty and silently does nothing — a quiet no-op
314
- // is worse than not trying.
315
- assert.deepEqual(G.npmForward({ npmEnvFile: '/etc/x/npm.env' }), ['NPM_TOKEN']);
316
- assert.deepEqual(G.npmForward({}), []);
315
+ test('an empty token ABORTS rather than half-running', () => {
316
+ // An empty token sends npm to the PUBLIC registry, which 404s the private core, which
317
+ // auto-rolls-back "cleanly" — a failure that teaches you nothing. Fail loudly instead.
318
+ assert.match(G.NPM_AUTH_PREAMBLE, /exit 12/);
317
319
  });
318
320
 
319
- test('an unvetted variable name never reaches the command', () => {
320
- const cmd = G.asOwner({ runAs: 'me' }, 'x', { forward: ['GOOD', 'bad name', '$(id)', '1BAD'] });
321
- assert.match(cmd, /--preserve-env=GOOD/);
322
- assert.ok(!cmd.includes('bad name') && !cmd.includes('$(id)') && !cmd.includes('1BAD'));
323
- });
324
-
325
- test('with no privilege drop a secret needs no mention at all', () => {
326
- // The child already inherits our environment, so naming it would be pure exposure.
327
- const cmd = G.asOwner({ env: { A: 'b' } }, 'x', { forward: ['NPM_TOKEN'] });
328
- assert.equal(cmd, 'env A=b x');
321
+ test('a command that does not need npm is left alone', () => {
322
+ const cmd = G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi');
323
+ assert.equal(cmd, 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
329
324
  assert.ok(!cmd.includes('NPM_TOKEN'));
330
325
  });
331
326
 
332
- test('no forwarding leaves the documented sudo shape untouched', () => {
333
- assert.equal(G.asOwner({ runAs: 'me', env: { PGDATABASE: 'mydb' } }, 'echo hi'),
334
- 'sudo -u me -H env PGDATABASE=mydb HOME=/home/me echo hi');
327
+ test('with no privilege drop the command still derives its own auth', () => {
328
+ const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x', { needsNpm: true });
329
+ assert.ok(!cmd.startsWith('sudo'));
330
+ assert.match(cmd, /_authToken/);
335
331
  });
336
332
 
337
- // ---------------------------------------------------------------------------
338
- // A failed run must say what the tool said
339
- // ---------------------------------------------------------------------------
340
-
341
333
  test('the report surfaces the failing tool’s own words', () => {
342
334
  const out = G.renderReport({
343
335
  ok: false, failedAt: 'UPGRADE', targetName: 'web', from: '1.19.305', to: '1.19.306',
@@ -378,3 +370,44 @@ test('a spawn error is handled, not thrown', () => {
378
370
  const r = G.refreshMain({ run: () => ({ error: new Error('ENOENT'), status: null }) });
379
371
  assert.equal(r.ok, false);
380
372
  });
373
+
374
+ // ---------------------------------------------------------------------------
375
+ // Local execution — one engine, two callers (task 1003219)
376
+ //
377
+ // The nightly lane runs ON the box, so it cannot reach the deploy path through ssh.
378
+ // Without a local mode it would need a second implementation, and the two would
379
+ // drift — which is exactly how the unattended deploy ended up with WEAKER checks
380
+ // than the manual one (no backup, no timer guard, no independent read-back).
381
+ // ---------------------------------------------------------------------------
382
+
383
+ test('a target with no ssh host is local; one with an ssh host is not', () => {
384
+ const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
385
+ assert.equal(G.isLocalTarget(base), true);
386
+ assert.equal(G.isLocalTarget({ ...base, ssh: 'my-box' }), false);
387
+ });
388
+
389
+ test('ssh is OPTIONAL — a local target validates clean', () => {
390
+ const local = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
391
+ assert.deepEqual(G.validateTarget('local', local), []);
392
+ });
393
+
394
+ test('the remaining required fields are still enforced for a local target', () => {
395
+ const errs = G.validateTarget('local', { instanceDir: '/i' });
396
+ for (const f of ['service', 'healthUrl', 'versionUrl', 'deployTimer']) {
397
+ assert.match(errs.join(' '), new RegExp(`missing required field "${f}"`));
398
+ }
399
+ // ssh must NOT be demanded any more.
400
+ assert.ok(!/missing required field "ssh"/.test(errs.join(' ')));
401
+ });
402
+
403
+ test('`local` is a boolean flag, not a shell token', () => {
404
+ const base = { instanceDir: '/i', service: 's.service', healthUrl: 'http://h', versionUrl: 'http://v', deployTimer: 'd.timer' };
405
+ assert.deepEqual(G.validateTarget('t', { ...base, local: true }), []);
406
+ assert.match(G.validateTarget('t', { ...base, local: 'yes' }).join(' '), /must be true or false/);
407
+ });
408
+
409
+ test('gitRefExists reports rather than throwing', () => {
410
+ assert.equal(G.gitRefExists('x', { run: () => ({ status: 0 }) }), true);
411
+ assert.equal(G.gitRefExists('x', { run: () => ({ status: 1 }) }), false);
412
+ assert.equal(G.gitRefExists('x', { run: () => ({ error: new Error('ENOENT') }) }), false);
413
+ });
@@ -184,3 +184,36 @@ t('loadSubscriptions: an entry without a dir is dropped (cannot be upgraded)', (
184
184
 
185
185
  console.log(`\nupdate_channel: ${passed} passed, ${failed} failed`);
186
186
  process.exit(failed ? 1 : 0);
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Deploy topology survives normalization (task 1003219)
190
+ //
191
+ // The sweep can only hand a bump to go-live.js — and so inherit its backup, its
192
+ // pull-deploy-timer guard and its independent box-side read-back — if the roster's
193
+ // topology actually REACHES the entry. The first cut of this dropped the new fields
194
+ // on the floor, so an instance that named them looked enrolled while the sweep
195
+ // quietly kept using the weaker direct path. A normalizer that silently discards a
196
+ // field is indistinguishable from a feature that does not work.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ test('deploy topology is carried through loadSubscriptions', () => {
200
+ const entry = {
201
+ slug: 's', dir: '/i', service: 'x.service', healthUrl: 'http://h',
202
+ versionUrl: 'http://v', deployTimer: 'd.timer', deployService: 'd.service',
203
+ backupDb: 'db', backupDir: '~/b', registryPackage: '@scope/core',
204
+ };
205
+ const fsImpl = { readFileSync: () => JSON.stringify({ instances: [entry] }) };
206
+ const { instances } = loadSubscriptions({ instanceDir: '/x', fsImpl });
207
+ assert.equal(instances.length, 1);
208
+ for (const k of ['versionUrl', 'deployTimer', 'deployService', 'backupDb', 'backupDir', 'registryPackage']) {
209
+ assert.equal(instances[0][k], entry[k], `${k} must survive normalization`);
210
+ }
211
+ });
212
+
213
+ test('an entry that names no topology reports null, not undefined', () => {
214
+ const fsImpl = { readFileSync: () => JSON.stringify({ instances: [{ slug: 's', dir: '/i' }] }) };
215
+ const { instances } = loadSubscriptions({ instanceDir: '/x', fsImpl });
216
+ for (const k of ['versionUrl', 'deployTimer', 'deployService', 'backupDb', 'backupDir']) {
217
+ assert.equal(instances[0][k], null, `${k} should be an explicit null`);
218
+ }
219
+ });