@bongos/core 1.19.736 → 1.19.738

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/tests/go_live.mjs CHANGED
@@ -442,26 +442,27 @@ test("'could not check' is a distinct halt from 'absent'", () => {
442
442
  // `env K=V` list that omits it drops it too. This cost a real failed go-live.
443
443
  // ---------------------------------------------------------------------------
444
444
 
445
- test('a privileged npm command DERIVES its token — no secret in argv at all', () => {
446
- const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install', { needsNpm: true });
447
- // THE REGRESSION, TWICE (task 1003218). `env NPM_TOKEN="$NPM_TOKEN"` put the value in
448
- // sudo's COMMAND= field. `--preserve-env=NPM_TOKEN` moved it to sudo's ENV= field —
449
- // a different field of the SAME log line, which is not a fix. sudo logs its whole
450
- // invocation, so the only safe answer is that the secret is never part of it.
451
- assert.ok(!/NPM_TOKEN=npm_/.test(cmd), 'no token VALUE may appear');
452
- assert.ok(!/--preserve-env/.test(cmd), '--preserve-env logs the value in ENV=');
453
- assert.ok(!/env [^;]*NPM_TOKEN="\$NPM_TOKEN"/.test(cmd), 'no by-reference assignment in argv either');
454
- // What SHOULD be there: the child deriving it, and refusing to run on an empty read.
455
- assert.match(cmd, /_authToken/);
456
- assert.match(cmd, /EMPTY NPM_TOKEN/);
445
+ test('a privileged npm command carries NO credential machinery at all', () => {
446
+ const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install');
447
+ // HISTORY, so nobody reintroduces it (task 1003218, twice). `env NPM_TOKEN="$NPM_TOKEN"`
448
+ // put the value in sudo's COMMAND= field; `--preserve-env=NPM_TOKEN` moved it to sudo's
449
+ // ENV= field — a different field of the SAME log line, which was never a fix. The third
450
+ // shape had the child DERIVE the token from a file so only a `sed` appeared in argv.
451
+ //
452
+ // All three are now moot rather than mitigated: @bongos/core is public (task 1003872), so
453
+ // the deploy path needs no npm credential and the hazard cannot recur (task 1003878). This
454
+ // asserts the ABSENCE, which is a stronger guarantee than any of the three mitigations.
455
+ assert.doesNotMatch(cmd, /NPM_TOKEN/, 'no token by value, by reference, or by derivation');
456
+ assert.doesNotMatch(cmd, /--preserve-env/, '--preserve-env would log the value in ENV=');
457
+ assert.doesNotMatch(cmd, /_authToken/, 'nothing reads a credential out of an .npmrc');
457
458
  // Non-secret config still travels plainly.
458
459
  assert.match(cmd, /PGDATABASE=cb/);
459
460
  });
460
461
 
461
- test('an empty token ABORTS rather than half-running', () => {
462
- // An empty token sends npm to the PUBLIC registry, which 404s the private core, which
463
- // auto-rolls-back "cleanly" — a failure that teaches you nothing. Fail loudly instead.
464
- assert.match(G.NPM_AUTH_PREAMBLE, /exit 12/);
462
+ test('the retired auth preamble is really gone from the module surface', () => {
463
+ // It was exported and embedded into every privileged command. A re-export would mean the
464
+ // mitigation came back, which now means a credential came back with it.
465
+ assert.equal(G.NPM_AUTH_PREAMBLE, undefined, 'NPM_AUTH_PREAMBLE must not be exported');
465
466
  });
466
467
 
467
468
  test('a command that does not need npm is left alone', () => {
@@ -470,10 +471,10 @@ test('a command that does not need npm is left alone', () => {
470
471
  assert.ok(!cmd.includes('NPM_TOKEN'));
471
472
  });
472
473
 
473
- test('with no privilege drop the command still derives its own auth', () => {
474
- const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x', { needsNpm: true });
474
+ test('with no privilege drop the command is still credential-free', () => {
475
+ const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x');
475
476
  assert.ok(!cmd.startsWith('sudo'));
476
- assert.match(cmd, /_authToken/);
477
+ assert.doesNotMatch(cmd, /NPM_TOKEN|_authToken/);
477
478
  });
478
479
 
479
480
  test('the report surfaces the failing tool’s own words', () => {
package/tests/init.mjs CHANGED
@@ -768,10 +768,10 @@ async function runSeedTests() {
768
768
  });
769
769
  }
770
770
 
771
- // ---- greenfield registry-install path (ADR 0134, task 2090) ----------------
771
+ // ---- greenfield registry-install path ---------------------------------------
772
772
  // main() with --registry-core --dry-run only LOGS (no fs, no network), so we capture
773
- // console output and assert the plan. The real write+install path is proven by the
774
- // CLI scratch run in the session log; the pure gates live in tests/npmrc.mjs.
773
+ // console output and assert the plan. The package is public (task 1003872), so this path
774
+ // writes no `.npmrc` and needs no token; tests/npmrc.mjs went with the module (task 1003878).
775
775
  async function runRegistryCoreTests() {
776
776
  const at = async (name, fn) => {
777
777
  try { await fn(); passed++; console.log(` ok ${name}`); }
@@ -799,14 +799,19 @@ async function runRegistryCoreTests() {
799
799
  return { code, out: lines.join('\n') };
800
800
  };
801
801
 
802
- await at('main --registry-core --dry-run: plans the env-fed .npmrc; does NOT vendor a tarball', async () => {
802
+ await at('main --registry-core --dry-run: plans a plain lockfile generation, no .npmrc, no token', async () => {
803
803
  const { dir, p } = specFile();
804
804
  try {
805
805
  const { code, out } = await capture(['--from', p, '--dir', join(dir, 'out'), '--no-seed', '--registry-core', '--dry-run']);
806
806
  assert.equal(code, 0);
807
- assert.match(out, /would write \.npmrc \(env-fed @bongos auth\)/);
807
+ assert.match(out, /would run `npm install` to generate package-lock\.json/);
808
808
  assert.ok(!/vendor/i.test(out), 'no vendored-tarball step on the registry path');
809
- assert.equal(existsSync(join(dir, 'out', '.npmrc')), false, 'dry-run writes nothing');
809
+ // The load-bearing half (task 1003878): a public package resolves from the default
810
+ // registry, so planning an `.npmrc` or mentioning a token would mean the retired
811
+ // auth path came back.
812
+ assert.doesNotMatch(out, /\.npmrc/, 'must not plan an .npmrc');
813
+ assert.doesNotMatch(out, /NPM_TOKEN/, 'must not mention a token');
814
+ assert.equal(existsSync(join(dir, 'out', '.npmrc')), false, 'writes no .npmrc at all');
810
815
  } finally { rmSync(dir, { recursive: true, force: true }); }
811
816
  });
812
817
 
@@ -599,18 +599,41 @@ async function scaffoldWithEnv(env) {
599
599
  }
600
600
  }
601
601
 
602
- await ta('scaffoldStandaloneRepo: PROVISION_CORE_FROM_REGISTRY=1 + NPM_TOKEN → registry path (init --registry-core, no tarball build)', async () => {
602
+ // Provisioning used to FORK on two env vars: PROVISION_CORE_FROM_REGISTRY chose the registry
603
+ // path, and a missing NPM_TOKEN silently failed it soft back to the vendored tarball. Both are
604
+ // gone (task 1003878) — the package is public, so nothing needs a token, and an env var that
605
+ // silently changed which install path ran is the divergence behind task 1003520.
606
+ //
607
+ // These two assert the ABSENCE of that fork, which is why they set the retired vars explicitly:
608
+ // a test that merely stopped mentioning them would pass whether or not the fork came back.
609
+ await ta('scaffoldStandaloneRepo: the retired PROVISION_CORE_FROM_REGISTRY env no longer diverts to the registry', async () => {
603
610
  const joined = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1', NPM_TOKEN: 'npm_readonly' });
604
- assert.match(joined, /init\.js --from .* --dir .* --no-seed --force --registry-core/, 'materializes via --registry-core');
605
- assert.ok(!/package-core\.js/.test(joined), 'does NOT build a core tarball on the registry path');
606
- assert.ok(!/--vendor-core/.test(joined), 'does NOT vendor a tarball on the registry path');
607
- });
608
-
609
- await ta('scaffoldStandaloneRepo: registry requested but NO NPM_TOKEN → FAILS SOFT to the vendored tarball', async () => {
610
- const joined = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1' });
611
- assert.match(joined, /node scripts\/gds\/package-core\.js/, 'builds the core tarball (vendored fallback)');
611
+ assert.match(joined, /node scripts\/gds\/package-core\.js/, 'still vendors — the env var is inert');
612
612
  assert.match(joined, /init\.js --from .* --dir .* --no-seed --force --vendor-core /, 'file:-pins the vendored tarball');
613
- assert.ok(!/--registry-core/.test(joined), 'does NOT take the registry path without a token');
613
+ assert.ok(!/--registry-core/.test(joined), 'the retired env var must not resurrect the registry path');
614
+ });
615
+
616
+ await ta('scaffoldStandaloneRepo: a token in env changes NOTHING about the install path', async () => {
617
+ // BOTH runs set the retired request flag and vary ONLY the token — that is precisely the
618
+ // old divergence (flag + token → registry; flag, no token → vendored fail-soft). Comparing
619
+ // two runs that DON'T set the flag would pass whether or not the fork existed, which an
620
+ // adversarial re-add of the fork proved while this test was written that way.
621
+ const withToken = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1', NPM_TOKEN: 'npm_readonly' });
622
+ const without = await scaffoldWithEnv({ PROVISION_CORE_FROM_REGISTRY: '1' });
623
+ // THE LOAD-BEARING ASSERTION. The old fail-soft made a tokenless standup take a DIFFERENT
624
+ // install path than a tokenized one — two modes diverging on an env var (task 1003520).
625
+ // One public path means the presence of a credential cannot change the outcome at all.
626
+ // Normalize the per-run random temp names (scratch dir, spec file, pack dir) — they differ
627
+ // between any two runs and say nothing about which install path was taken.
628
+ const norm = (t) => t
629
+ .replace(/scaffold-[A-Za-z0-9]+/g, 'SCRATCH')
630
+ .replace(/bongos-init-[^\s.]+/g, 'SPEC')
631
+ .replace(/core-pack-[^\s/]+/g, 'PACK');
632
+ assert.equal(
633
+ norm(withToken),
634
+ norm(without),
635
+ 'the command sequence must be identical with and without a token',
636
+ );
614
637
  });
615
638
 
616
639
  await ta('scaffoldStandaloneRepo: default (no registry env) stays on the vendored tarball path', async () => {
@@ -107,10 +107,15 @@ t('a hosted origin leads with the board being a website that needs no install',
107
107
  assert.ok(/healthz/.test(card), 'offers the health probe instead of a bring-up');
108
108
  });
109
109
 
110
- t('a hosted origin explains the private-package 404 rather than implying a lost version', () => {
110
+ t('a hosted origin says the core installs with no token, and never re-offers the retired NPM_TOKEN advice', () => {
111
111
  const card = instanceNotRunningCard({ origin: 'https://example-instance.cloudbongos.com', depsMissing: true });
112
- assert.ok(/private/i.test(card) && /NPM_TOKEN/.test(card), 'names the real cause of a tokenless install failure');
113
- assert.ok(/not a missing version/.test(card), 'kills the "go find a version that still exists" wild goose chase');
112
+ assert.ok(/public/i.test(card), 'names the package as public — it is (task 1003872)');
113
+ assert.ok(/no token|needs no token/i.test(card), 'tells the reader nothing needs authenticating');
114
+ // The load-bearing half: this card spent months telling people to go find an NPM_TOKEN for
115
+ // a package that is now public. Advising that again would send a newcomer hunting a
116
+ // credential that does not exist and cannot be issued to them.
117
+ assert.doesNotMatch(card, /NPM_TOKEN/, 'must not resurrect the token advice');
118
+ assert.doesNotMatch(card, /\bprivate\b/i, 'must not call the package private');
114
119
  });
115
120
 
116
121
  t('isLocalOrigin: loopback is local, everything else is remote', () => {
package/tests/upgrade.mjs CHANGED
@@ -9,12 +9,15 @@
9
9
  // 5. dry-run — plans without mutating package.json or spawning npm.
10
10
  // 6. full happy-path — pre-flight → pin → install → migrate → materialize → restart → verify,
11
11
  // with every side effect injected (DB-free: the ledger is skipped when no DATABASE_URL).
12
+ // 10. the pin push on a PRIVATE instance rides the owner's GitHub token through a chmod-600
13
+ // credential-store file, falls back to the remote only when none is stored, and a refused
14
+ // push is LOUD, naming the read-only deploy key and the fix (task 1003081, ADR 0176).
12
15
  //
13
16
  // Run: node tests/upgrade.mjs
14
17
 
15
18
  import { strict as assert } from 'node:assert';
16
19
  import { createRequire } from 'node:module';
17
- import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
20
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'node:fs';
18
21
  import { tmpdir } from 'node:os';
19
22
  import { join, dirname } from 'node:path';
20
23
 
@@ -441,7 +444,7 @@ t('runUpgrade --registry + --from: refuses (mutually exclusive core sources)', a
441
444
  assert.match(res.error, /mutually exclusive/);
442
445
  });
443
446
 
444
- t('runUpgrade --registry (full): pins the exact version + writes the env-fed .npmrc, then install→migrate→verify', async () => {
447
+ t('runUpgrade --registry (full): pins the exact version, writes NO .npmrc, then install→migrate→verify', async () => {
445
448
  const dir = scratchConsumer({ pinned: '1.16.0', installed: '1.16.0' });
446
449
  const calls = [];
447
450
  const res = await u.runUpgrade(
@@ -457,8 +460,11 @@ t('runUpgrade --registry (full): pins the exact version + writes the env-fed .np
457
460
  assert.equal(res.ok, true, 'registry upgrade succeeds');
458
461
  assert.equal(res.toVersion, '1.17.0');
459
462
  assert.equal(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).dependencies['@bongos/core'], '1.17.0', 'exact registry pin (no file:)');
460
- const npmrc = readFileSync(join(dir, '.npmrc'), 'utf8');
461
- assert.match(npmrc, /_authToken=\$\{NPM_TOKEN\}/, 'wrote the env-fed .npmrc');
463
+ // @bongos/core is public (task 1003872), so the registry path writes no `.npmrc` and
464
+ // resolves from the default registry. An `.npmrc` reappearing here would mean the retired
465
+ // auth plumbing came back — and it is the file whose `${NPM_TOKEN}` expansion used to
466
+ // SHADOW a working user-level credential (task 1002754), so its absence is load-bearing.
467
+ assert.equal(existsSync(join(dir, '.npmrc')), false, 'writes no .npmrc on the registry path');
462
468
  assert.ok(calls.some((c) => /^npm install/.test(c)), 'ran npm install (resolves from the registry)');
463
469
  assert.ok(!calls.some((c) => /vendor/.test(c)), 'did not vendor a tarball');
464
470
  });
@@ -1390,6 +1396,286 @@ t('pollServedVersion: gives up rather than spinning when the clock never advance
1390
1396
  assert.match(r.error, /no coreVersion/);
1391
1397
  });
1392
1398
 
1399
+ // ---- 10. the pin push rides the owner's GitHub token on a private instance (task 1003081, ADR 0176 follow-up) ----
1400
+ //
1401
+ // A PRIVATE standalone instance's origin authenticates with a deploy key GitHub registers
1402
+ // READ-ONLY, so `git push origin HEAD:main` is refused and the pin bump silently un-does
1403
+ // itself at the next pull-deploy. persistPin now prefers the owner's stored `repo` token
1404
+ // (the scaffold's + refreshStandaloneCorePin's credential-store shape), falls back to the
1405
+ // remote only when none is stored, and a refused push is LOUD. `run` is injected, so no
1406
+ // real repo, remote, or network — the assertions are on the argv persistPin CHOOSES and
1407
+ // on the credential file it writes beside it.
1408
+
1409
+ const PIN_DIRTY = ' M package.json\n M package-lock.json\n';
1410
+ const hush = () => {};
1411
+ // A `run` fake for persistPin: `git -c <cfg> push …` is routed as the verb `push`, so a
1412
+ // handler sees the token-path push and the origin push alike. Unset config / no such
1413
+ // remote by default (exit 1), the way git answers a checkout that is not wired.
1414
+ function pinRun(handlers = {}) {
1415
+ const calls = [];
1416
+ const fn = (cmd, args, opts) => {
1417
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1418
+ const verb = args[0] === '-c' ? args[2] : args[0];
1419
+ const h = handlers[verb];
1420
+ if (typeof h === 'function') return h(args, opts);
1421
+ if (h) return h;
1422
+ if (verb === 'status') return { status: 0, stdout: PIN_DIRTY };
1423
+ if (verb === 'rev-parse') return { status: 0, stdout: 'main\n' };
1424
+ if (verb === 'config' || verb === 'remote') return { status: 1, stdout: '' };
1425
+ return { status: 0, stdout: '' };
1426
+ };
1427
+ fn.calls = calls;
1428
+ return fn;
1429
+ }
1430
+ const pinBase = { instanceDir: '/srv/bongos/acme', fromVersion: '1.19.56', toVersion: '1.19.69', commitPin: true };
1431
+ const acme = { owner: 'acme', name: 'widgets' };
1432
+ const credFileOf = (args) => (String(args[1] || '').match(/^credential\.helper=store --file=(.+)$/) || [])[1] || null;
1433
+
1434
+ t('persistPin: a stored owner token pushes over HTTPS through a chmod-600 credential-store file, and the token never rides argv', () => {
1435
+ const token = 'gho_SECRET_OWNER_TOKEN';
1436
+ let seen = null;
1437
+ const run = pinRun({
1438
+ push: (args, opts) => {
1439
+ const credFile = credFileOf(args);
1440
+ const there = !!credFile && existsSync(credFile);
1441
+ seen = {
1442
+ credFile, there,
1443
+ mode: there ? statSync(credFile).mode & 0o777 : null,
1444
+ body: there ? readFileSync(credFile, 'utf8') : null,
1445
+ url: args[3], target: args[4],
1446
+ noPrompt: opts && opts.env && opts.env.GIT_TERMINAL_PROMPT,
1447
+ };
1448
+ return { status: 0, stdout: '' };
1449
+ },
1450
+ });
1451
+ const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, hush);
1452
+ assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES, via: 'owner-token' });
1453
+ assert.ok(seen, 'the push ran');
1454
+ assert.ok(seen.credFile, `the push reads a credential-store file; argv was ${JSON.stringify(run.calls)}`);
1455
+ assert.equal(seen.url, 'https://x-access-token@github.com/acme/widgets.git', 'HTTPS at the remote repo, the placeholder user in the URL');
1456
+ assert.equal(seen.target, 'HEAD:main');
1457
+ assert.equal(seen.there, true, 'the credential file exists while git runs');
1458
+ if (process.platform !== 'win32') assert.equal(seen.mode, 0o600, 'chmod 600 — the secret is readable by this user only');
1459
+ assert.equal(seen.body, `https://x-access-token:${token}@github.com\n`, 'the token lives in the file, credential-store shape');
1460
+ assert.equal(seen.noPrompt, '0', 'a rejected credential fails instead of sitting at a password prompt');
1461
+ assert.ok(!existsSync(seen.credFile), 'removed in finally');
1462
+ assert.ok(run.calls.every((c) => !c.includes(token)), `the token never rides argv: ${JSON.stringify(run.calls)}`);
1463
+ assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'the SSH origin was not used');
1464
+ assert.ok(run.calls.some((c) => c === `git add ${u.PIN_FILES.join(' ')}`) && run.calls.some((c) => c.startsWith('git commit -m pin @bongos/core 1.19.69')), 'stage + commit are unchanged');
1465
+ });
1466
+
1467
+ t('persistPin: no stored token → the push falls back to the git remote, with no credential helper anywhere', () => {
1468
+ const run = pinRun();
1469
+ const r = u.persistPin({ ...pinBase }, run, hush, hush);
1470
+ assert.deepEqual(r, { applicable: true, committed: true, pushed: true, dirty: u.PIN_FILES }, 'the origin-path result is byte-for-byte what every existing caller reads');
1471
+ assert.ok(run.calls.some((c) => c === 'git push origin HEAD:main'), 'pushes via the remote');
1472
+ assert.ok(!run.calls.some((c) => /credential\.helper|x-access-token/.test(c)), 'no credential helper, no token URL');
1473
+ // `pushAuth: null` — what resolvePinPushAuth returns on every miss — is the same path.
1474
+ const run2 = pinRun();
1475
+ assert.equal(u.persistPin({ ...pinBase, pushAuth: null }, run2, hush, hush).pushed, true);
1476
+ assert.ok(run2.calls.some((c) => c === 'git push origin HEAD:main'));
1477
+ });
1478
+
1479
+ t('persistPin: a refused origin push on a WIRED private instance is loud — names the read-only key, the consequence, and the fix', () => {
1480
+ const warnings = [];
1481
+ const run = pinRun({
1482
+ push: { status: 128, stdout: '', stderr: 'ERROR: The key you are authenticating with has been marked as read only.\nfatal: Could not read from remote repository.\n' },
1483
+ config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new\n' },
1484
+ remote: { status: 0, stdout: 'git@github.com:acme/widgets.git\n' },
1485
+ });
1486
+ const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
1487
+ assert.equal(r.committed, true);
1488
+ assert.equal(r.pushed, false);
1489
+ assert.equal(r.refused, 'read-only-deploy-key');
1490
+ // The tokenless refusal is a plain sentence, not the bare status line.
1491
+ assert.match(r.error, /read-only deploy key at \/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key');
1492
+ assert.match(r.error, /no owner GitHub token is stored/, 'and why nothing else was tried');
1493
+ assert.notEqual(r.error, 'git push failed');
1494
+ const text = warnings.join('\n');
1495
+ assert.match(text, /PIN NOT PUSHED/, 'shouts');
1496
+ assert.match(text, /\/srv\/bongos\/acme\/\.git\/cloudbongos-deploy-key/, 'names the key on stderr too');
1497
+ assert.match(text, /READ-ONLY/);
1498
+ assert.match(text, /reset --hard/, 'names the mechanism that reverts it');
1499
+ assert.match(text, /npm ci/);
1500
+ assert.match(text, /1\.19\.56/, 'and the version it would fall back to');
1501
+ assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
1502
+ assert.match(text, /--commit-pin/);
1503
+ assert.match(text, /--instance '\/srv\/bongos\/acme'/, 'and where to re-run it');
1504
+ assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'plus a by-hand push at the real repo, paste-safe');
1505
+ assert.match(text, /marked as read only/, "git's own reason rides along");
1506
+ });
1507
+
1508
+ t('persistPin: a refused origin push on an UN-wired checkout keeps the plain failure (no false read-only diagnosis)', () => {
1509
+ const warnings = [];
1510
+ const run = pinRun({ push: { status: 1, stdout: '', stderr: 'fatal: unable to access: Could not resolve host\n' } });
1511
+ const r = u.persistPin({ ...pinBase }, run, hush, (m) => warnings.push(m));
1512
+ assert.equal(r.pushed, false);
1513
+ assert.equal(r.error, 'git push failed');
1514
+ assert.equal(r.refused, undefined);
1515
+ assert.ok(!/READ-ONLY|deploy key/.test(warnings.join('\n')), 'no read-only story when no key is pinned');
1516
+ assert.match(warnings.join('\n'), /still not durable/i);
1517
+ });
1518
+
1519
+ t('persistPin: a refused push OVER the token is loud too, and the credential file is still removed', () => {
1520
+ const token = 'gho_EXPIRED_TOKEN';
1521
+ const warnings = [];
1522
+ let credFile = null;
1523
+ const run = pinRun({
1524
+ push: (args) => {
1525
+ credFile = credFileOf(args);
1526
+ return { status: 128, stdout: '', stderr: 'remote: Write access to repository not granted.\nfatal: unable to access \'https://github.com/acme/widgets.git/\': The requested URL returned error: 403\n' };
1527
+ },
1528
+ });
1529
+ const r = u.persistPin({ ...pinBase, pushAuth: { token, repo: acme } }, run, hush, (m) => warnings.push(m));
1530
+ assert.equal(r.committed, true);
1531
+ assert.equal(r.pushed, false);
1532
+ assert.equal(r.refused, 'owner-token');
1533
+ assert.match(r.error, /over the owner's GitHub token/);
1534
+ assert.match(r.error, /expired, or missing the repo scope/);
1535
+ assert.ok(credFile && !existsSync(credFile), 'the credential file is gone even on failure');
1536
+ const text = warnings.join('\n');
1537
+ assert.match(text, /PIN NOT PUSHED/);
1538
+ assert.match(text, /expired/);
1539
+ assert.match(text, /repo.*scope/);
1540
+ assert.match(text, /reconnect GitHub with repository access/, 'names the fix');
1541
+ assert.match(text, /Write access to repository not granted/, "GitHub's own reason rides along, not git's generic fatal line");
1542
+ assert.match(text, /push 'https:\/\/github\.com\/acme\/widgets\.git' 'HEAD:main'/, 'and a by-hand push');
1543
+ assert.ok(!text.includes(token), 'the token is never logged');
1544
+ assert.ok(!run.calls.some((c) => /^git push /.test(c)), 'a token refusal does not retry the read-only origin');
1545
+ });
1546
+
1547
+ t('pinnedDeployKey / remoteGithubRepo: read the ADR 0176 wiring off the checkout, and only GitHub remotes count', () => {
1548
+ const wired = pinRun({ config: { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' } });
1549
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', wired), '/srv/bongos/acme/.git/cloudbongos-deploy-key');
1550
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun()), null, 'unset core.sshCommand → not wired');
1551
+ assert.equal(u.pinnedDeployKey('/srv/bongos/acme', pinRun({ config: { status: 0, stdout: 'ssh -o BatchMode=yes\n' } })), null, 'a sshCommand without -i pins no key');
1552
+
1553
+ const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
1554
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@github.com:acme/widgets.git')), acme, 'the ADR 0176 private wiring');
1555
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://github.com/acme/widgets')), acme);
1556
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://x-access-token@github.com/acme/widgets.git')), acme);
1557
+ const P = require('../scripts/gds/provision.js');
1558
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at(P.instanceRepoRemote(acme))), acme, 'the shared SSH alias the public scaffold wires is GitHub too');
1559
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('https://gitlab.com/acme/widgets.git')), null, 'another host is never pointed at github.com');
1560
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', at('git@bitbucket.org:acme/widgets.git')), null);
1561
+ assert.equal(u.remoteGithubRepo('/srv/bongos/acme', 'origin', pinRun()), null, 'no such remote');
1562
+ // --git-remote is honoured: the URL of THAT remote is read.
1563
+ const deploy = pinRun({ remote: (args) => (args[2] === 'deploy' ? { status: 0, stdout: 'git@github.com:acme/widgets.git\n' } : { status: 1, stdout: '' }) });
1564
+ assert.deepEqual(u.remoteGithubRepo('/srv/bongos/acme', 'deploy', deploy), acme);
1565
+ });
1566
+
1567
+ t('resolvePinPushAuth: the owner token is spent only when one is stored AND the provisioning row names the remote repo', async () => {
1568
+ const inst = { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' };
1569
+ const at = (url) => pinRun({ remote: { status: 0, stdout: `${url}\n` } });
1570
+ const dir = { instanceDir: '/srv/bongos/acme' };
1571
+
1572
+ // stored token + matching row → { token, repo }, the repo parsed from the REMOTE
1573
+ const a = await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async (bid) => (bid === 7 ? { token: 'gho_T', scope: 'read:user,repo' } : null) }, at('git@github.com:acme/widgets.git'));
1574
+ assert.deepEqual(a, { token: 'gho_T', repo: acme });
1575
+ // case never decides it (GitHub owner/repo names are case-insensitive)
1576
+ const b = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'Acme/Widgets' }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
1577
+ assert.equal(b && b.token, 'gho_T');
1578
+ // no token stored → null: the origin path
1579
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => null }, at('git@github.com:acme/widgets.git')), null);
1580
+ // a row for ANOTHER repo → null: never another instance's credential for this checkout
1581
+ let asked = 0;
1582
+ const other = await u.resolvePinPushAuth(dir, { log: hush, instance: { ...inst, target_ref: 'acme/other' }, getGithubToken: async () => { asked++; return { token: 'gho_T' }; } }, at('git@github.com:acme/widgets.git'));
1583
+ assert.equal(other, null);
1584
+ assert.equal(asked, 0, 'the token store is not even consulted for a mismatched row');
1585
+ // a non-GitHub remote → null, before any row or token lookup
1586
+ let rows = 0;
1587
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { rows++; return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('https://gitlab.com/acme/widgets.git')), null);
1588
+ assert.equal(rows, 0);
1589
+ // no row at all (an instance's own DB, a laptop) → null, and a THROWING lookup is the same answer
1590
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => null, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1591
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => { throw new Error('relation "provisioning_instances" does not exist'); }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1592
+ // the by-slug lookup uses the checkout's basename — standaloneRoot is <base>/<slug>
1593
+ const slugs = [];
1594
+ const c = await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async (s) => { slugs.push(s); return inst; }, getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git'));
1595
+ assert.deepEqual(slugs, ['acme']);
1596
+ assert.equal(c && c.token, 'gho_T');
1597
+ // a row without an owner (or a token lookup that throws) is the origin path, never a crash
1598
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, getInstanceBySlug: async () => ({ ...inst, owner_builder_id: null }), getGithubToken: async () => ({ token: 'gho_T' }) }, at('git@github.com:acme/widgets.git')), null);
1599
+ assert.equal(await u.resolvePinPushAuth(dir, { log: hush, instance: inst, getGithubToken: async () => { throw new Error('secret-box off'); } }, at('git@github.com:acme/widgets.git')), null);
1600
+ });
1601
+
1602
+ t('runUpgrade --commit-pin: on a wired private instance the pin lands via the owner token, end to end', async () => {
1603
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1604
+ const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
1605
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1606
+ const token = 'gho_END_TO_END_TOKEN';
1607
+ const calls = [];
1608
+ let pushArgv = null;
1609
+ const run = (cmd, args, opts) => {
1610
+ calls.push(`${cmd} ${(args || []).join(' ')}`.trim());
1611
+ // pre-flight `git status --porcelain` is clean; persistPin's pathspec-limited look
1612
+ // (`-- package.json package-lock.json`) sees the moved pin.
1613
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') ? PIN_DIRTY : '' };
1614
+ if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
1615
+ if (cmd === 'git' && args[0] === 'remote') return { status: 0, stdout: 'git@github.com:acme/widgets.git\n' };
1616
+ if (cmd === 'git' && args[0] === 'config') return { status: 0, stdout: 'ssh -F /dev/null -i /srv/bongos/acme/.git/cloudbongos-deploy-key -o IdentitiesOnly=yes\n' };
1617
+ if (cmd === 'git' && args[0] === '-c') { pushArgv = args; return { status: 0, stdout: '' }; }
1618
+ if (cmd === 'npm' && args[0] === 'install') {
1619
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1620
+ }
1621
+ return { status: 0 }; // no stdout: resolveInstanceDb reads "could not run" (not fatal), as the happy-path test's shim does
1622
+ };
1623
+ const res = await u.runUpgrade(
1624
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, commitPin: true },
1625
+ {
1626
+ log: hush, err: hush, run,
1627
+ materialize: () => ({ skills: 35, settings: true, hooks: 3 }),
1628
+ regenerateApiArtifacts: () => ({ ok: true, ran: ['gen-api-docs.js', 'gen-api-client.js'] }),
1629
+ databaseUrl: '', pgDatabase: '',
1630
+ instance: { slug: 'acme', owner_builder_id: 7, target_ref: 'acme/widgets', hosting_shape: 'standalone' },
1631
+ getGithubToken: async () => ({ token, scope: 'read:user,repo' }),
1632
+ },
1633
+ );
1634
+ assert.equal(res.ok, true, `upgrade should succeed: ${res.error}`);
1635
+ assert.equal(res.pin && res.pin.pushed, true, 'the pin was pushed');
1636
+ assert.equal(res.pin.via, 'owner-token', 'over the owner token, not the read-only origin');
1637
+ assert.ok(pushArgv, 'pushed via the credential-store helper');
1638
+ assert.equal(pushArgv[3], 'https://x-access-token@github.com/acme/widgets.git');
1639
+ assert.equal(pushArgv[4], 'HEAD:main');
1640
+ assert.ok(!calls.some((c) => c.includes(token)), 'the token never rides argv');
1641
+ assert.ok(!calls.some((c) => /^git push /.test(c)), 'the read-only origin was never asked');
1642
+ });
1643
+
1644
+ t('runUpgrade: the token store is consulted only when a push is actually coming', async () => {
1645
+ // --pin-no-push, --dry-run, and a clean pin all skip resolvePinPushAuth entirely:
1646
+ // nothing reads a credential it will not spend.
1647
+ const cases = [
1648
+ { name: 'no --commit-pin', opts: {}, dirtyPin: true },
1649
+ { name: '--pin-no-push', opts: { commitPin: true, pinNoPush: true }, dirtyPin: true },
1650
+ { name: '--dry-run', opts: { commitPin: true, dryRun: true }, dirtyPin: true },
1651
+ { name: 'pin already committed', opts: { commitPin: true }, dirtyPin: false },
1652
+ ];
1653
+ for (const c of cases) {
1654
+ const dir = scratchConsumer({ pinned: '1.14.1', installed: '1.14.1' });
1655
+ const src = mkdtempSync(join(tmpdir(), 'otb-upgrade-src-'));
1656
+ writeFileSync(join(src, 'bongos-core-1.15.0.tgz'), 'TGZ');
1657
+ let resolved = 0;
1658
+ const run = (cmd, args) => {
1659
+ if (cmd === 'git' && args[0] === 'status') return { status: 0, stdout: args.includes('--') && c.dirtyPin ? PIN_DIRTY : '' };
1660
+ if (cmd === 'git' && args[0] === 'rev-parse') return { status: 0, stdout: 'main\n' };
1661
+ if (cmd === 'npm' && args[0] === 'install') {
1662
+ writeFileSync(join(dir, 'node_modules', '@bongos', 'core', 'package.json'), JSON.stringify({ name: '@bongos/core', version: '1.15.0' }) + '\n');
1663
+ }
1664
+ return { status: 0 };
1665
+ };
1666
+ await u.runUpgrade(
1667
+ { to: '1.15.0', from: join(src, 'bongos-core-1.15.0.tgz'), instance: dir, service: 'acme.service', healthCheck: false, ...c.opts },
1668
+ {
1669
+ log: hush, err: hush, run,
1670
+ materialize: () => ({}), regenerateApiArtifacts: () => ({ ok: true, ran: [] }),
1671
+ databaseUrl: '', pgDatabase: '',
1672
+ resolvePinPushAuth: async () => { resolved++; return null; },
1673
+ },
1674
+ );
1675
+ assert.equal(resolved, 0, `${c.name}: must not consult the token store`);
1676
+ }
1677
+ });
1678
+
1393
1679
  await Promise.all(pending);
1394
1680
  console.log(`\nupgrade: ${passed} passed, ${failed} failed`);
1395
1681
  process.exit(failed ? 1 : 0);
@@ -1,124 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
- //
4
- // scripts/gds/npmrc.js — env-fed `.npmrc` for installing the PRIVATE @bongos/* core
5
- // from the npm registry (ADR 0134, task 2090). The single place that knows the
6
- // registry host + scope + the `.npmrc` shape + the opt-in/token gates, shared by:
7
- // • init.js — the greenfield `--registry-core` scaffold
8
- // • provision.js — the standalone scaffold's registry path (opt-in, fail-soft)
9
- // • upgrade.js — the `--registry <version>` update channel
10
- //
11
- // WHY private + env-fed. `@bongos/core` publishes as a SCOPED, PRIVATE package (ADR
12
- // 0134): a private scoped package can only be installed with auth. Consumers
13
- // authenticate with an `.npmrc` whose token EXPANDS FROM THE ENVIRONMENT at install
14
- // time (`_authToken=${NPM_TOKEN}`) — so the committed file carries NO secret, and a
15
- // tokenless install 404s cleanly (the expected pre-launch outcome).
16
- //
17
- // The vendored-tarball path (task 2053) stays the DEFAULT/fallback until this registry
18
- // path is proven end-to-end on a real greenfield standup (ADR 0134 §Consequences).
19
-
20
- const fs = require('node:fs');
21
- const path = require('node:path');
22
-
23
- const REGISTRY_HOST = 'registry.npmjs.org';
24
- const CORE_SCOPE = '@bongos'; // the paid, canonical npm org (ADR 0134 scope correction, task 2092)
25
- const TOKEN_ENV = 'NPM_TOKEN';
26
- const REQUEST_ENV = 'PROVISION_CORE_FROM_REGISTRY';
27
-
28
- // The `.npmrc` a scaffolded instance commits. The token is the LITERAL `${NPM_TOKEN}`
29
- // (npm expands env vars in `.npmrc` values at read time), so the file is safe to
30
- // commit — it holds no secret. Scoping @bongos to the registry keeps every unrelated
31
- // install on the default public registry.
32
- function npmrcContent({ scope = CORE_SCOPE, host = REGISTRY_HOST, tokenEnv = TOKEN_ENV } = {}) {
33
- return [
34
- `; Cloud Bongos — env-fed auth for the PRIVATE ${scope}/* packages (ADR 0134, task 2090).`,
35
- `; The token EXPANDS FROM THE ENVIRONMENT at install time; nothing secret is committed here.`,
36
- `; \`npm ci\` needs ${tokenEnv} set (a read-only npm access token from the paid \`bongos\` org);`,
37
- `; without it the private package 404s — that is the expected tokenless outcome.`,
38
- `${scope}:registry=https://${host}/`,
39
- `//${host}/:_authToken=\${${tokenEnv}}`,
40
- '',
41
- ].join('\n');
42
- }
43
-
44
- // Is a registry token present in the environment? The fail-soft gate: provisioning
45
- // falls back to the vendored tarball when this is false, so a tokenless standup still
46
- // works (never a broken instance).
47
- function hasNpmToken(env = process.env) {
48
- return !!String(env[TOKEN_ENV] || '').trim();
49
- }
50
-
51
- // resolveNpmToken (task 1002754) — the install-time credential resolver that removes
52
- // the hand-exported-NPM_TOKEN step from deploys.
53
- //
54
- // THE TRAP. The committed instance `.npmrc` reads `_authToken=${NPM_TOKEN}`; npm gives
55
- // a per-project key precedence over the user-level one, so with NPM_TOKEN unset the
56
- // expansion is EMPTY and it MASKS a perfectly valid literal token in ~/.npmrc — npm
57
- // goes anonymous and the private core 404s. Every deploy then silently depended on the
58
- // operator remembering `export NPM_TOKEN=…` (the ADR-0162 shape: a human-gated step in
59
- // the ship path). npm's config syntax cannot express "env var, else fall through", so
60
- // the fix is to RESOLVE the credential here and feed it to the child install's env —
61
- // the committed file stays secret-free and unchanged.
62
- //
63
- // Order: env NPM_TOKEN wins (explicit operator intent), else the user-level ~/.npmrc's
64
- // literal `//<host>/:_authToken=<value>` (ignoring env-fed `${…}` values — those are
65
- // the trap, not a credential). Returns { token, source: 'env'|'user-npmrc'|null }.
66
- function resolveNpmToken({ env = process.env, homedir, fsImpl = fs, host = REGISTRY_HOST } = {}) {
67
- const fromEnv = String(env[TOKEN_ENV] || '').trim();
68
- if (fromEnv) return { token: fromEnv, source: 'env' };
69
- const home = homedir || (env.HOME || env.USERPROFILE || '');
70
- if (home) {
71
- try {
72
- const raw = fsImpl.readFileSync(path.join(home, '.npmrc'), 'utf8');
73
- const re = new RegExp(`^\\s*//${host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/:_authToken\\s*=\\s*(.+?)\\s*$`, 'm');
74
- const m = re.exec(raw);
75
- const val = m && m[1].replace(/^["']|["']$/g, '');
76
- if (val && !val.includes('${')) return { token: val, source: 'user-npmrc' };
77
- } catch { /* no user npmrc, unreadable — fall through to null */ }
78
- }
79
- return { token: null, source: null };
80
- }
81
-
82
- // Has the operator opted the standalone scaffold into the registry path? OFF by
83
- // default — the vendored tarball stays the default until the registry path is proven
84
- // end-to-end (ADR 0134). '1'/'true'/'yes'/'on' (case-insensitive) → true.
85
- function registryCoreRequested(env = process.env) {
86
- return /^(1|true|yes|on)$/i.test(String(env[REQUEST_ENV] || '').trim());
87
- }
88
-
89
- // Write the env-fed `.npmrc` into an instance dir. Default overwrites (a greenfield
90
- // scaffold owns the file); pass overwrite:false to leave a pre-existing `.npmrc`
91
- // untouched (an upgrade must not clobber a consumer's other registries). Returns the
92
- // path (or null when skipped). Dry-run writes nothing.
93
- function writeNpmrc(dir, { fsImpl = fs, dryRun = false, overwrite = true, log = () => {} } = {}) {
94
- const p = path.join(dir, '.npmrc');
95
- if (!overwrite && fsImpl.existsSync(p)) {
96
- log(` • .npmrc already present — left untouched (add \`${CORE_SCOPE}:registry\` + \`_authToken=\${${TOKEN_ENV}}\` if the private core doesn't resolve).`);
97
- return null;
98
- }
99
- if (dryRun) {
100
- log(` [dry-run] would write ${p} (env-fed ${CORE_SCOPE} auth; token via $${TOKEN_ENV}, never committed)`);
101
- return p;
102
- }
103
- fsImpl.writeFileSync(p, npmrcContent());
104
- log(` ✓ wrote .npmrc (env-fed ${CORE_SCOPE} auth; token via $${TOKEN_ENV}, never committed)`);
105
- return p;
106
- }
107
-
108
- module.exports = {
109
- REGISTRY_HOST, CORE_SCOPE, TOKEN_ENV, REQUEST_ENV,
110
- npmrcContent, hasNpmToken, resolveNpmToken, registryCoreRequested, writeNpmrc,
111
- };
112
-
113
- // Tiny CLI: `node scripts/gds/npmrc.js [--print | --write <dir>]` — print the `.npmrc`
114
- // body or materialize it into <dir>. Handy for a manual registry-standup rehearsal.
115
- if (require.main === module) {
116
- const args = process.argv.slice(2);
117
- const writeIdx = args.indexOf('--write');
118
- if (writeIdx !== -1 && args[writeIdx + 1]) {
119
- const p = writeNpmrc(path.resolve(args[writeIdx + 1]), { log: console.log });
120
- process.stdout.write(`${p}\n`);
121
- } else {
122
- process.stdout.write(npmrcContent());
123
- }
124
- }