@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/.bongos-core.json +31 -36
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/upgrading-the-core.md +3 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/go-live.js +18 -44
- package/scripts/gds/init.js +21 -29
- package/scripts/gds/provision-config.js +0 -8
- package/scripts/gds/provision-repo-auth.js +106 -0
- package/scripts/gds/provision-repo.js +34 -67
- package/scripts/gds/provision.js +3 -5
- package/scripts/gds/start.js +1 -1
- package/scripts/gds/update-channel.js +3 -3
- package/scripts/gds/upgrade.js +204 -41
- package/src/module-api.js +1 -1
- package/tests/executed_closure.mjs +8 -0
- package/tests/go_live.mjs +20 -19
- package/tests/init.mjs +11 -6
- package/tests/provision.mjs +33 -10
- package/tests/start_env_guard.mjs +8 -3
- package/tests/upgrade.mjs +290 -4
- package/scripts/gds/npmrc.js +0 -124
- package/tests/npmrc.mjs +0 -154
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
|
|
446
|
-
const cmd = G.asOwner({ runAs: 'lars', env: { PGDATABASE: 'cb' } }, 'npm install'
|
|
447
|
-
//
|
|
448
|
-
// sudo's COMMAND= field
|
|
449
|
-
// a different field of the SAME log line, which
|
|
450
|
-
//
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
//
|
|
455
|
-
assert.
|
|
456
|
-
assert.
|
|
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('
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
assert.
|
|
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
|
|
474
|
-
const cmd = G.asOwner({ env: { A: 'b' } }, 'npm x'
|
|
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.
|
|
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
|
|
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
|
|
774
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
package/tests/provision.mjs
CHANGED
|
@@ -599,18 +599,41 @@ async function scaffoldWithEnv(env) {
|
|
|
599
599
|
}
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
|
|
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, /
|
|
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), '
|
|
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
|
|
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(/
|
|
113
|
-
assert.ok(/
|
|
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
|
|
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
|
-
|
|
461
|
-
|
|
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);
|
package/scripts/gds/npmrc.js
DELETED
|
@@ -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
|
-
}
|