@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.
@@ -14,7 +14,11 @@ const crypto = require('node:crypto');
14
14
  const fs = require('node:fs');
15
15
  const os = require('node:os');
16
16
  const path = require('node:path');
17
- const { CONFIG, REPO_ROOT, coreVersionSafe, hasNpmToken, provisionerBotEmail, registryCoreRequested } = require('./provision-config.js');
17
+ const { CONFIG, REPO_ROOT, coreVersionSafe, provisionerBotEmail } = require('./provision-config.js');
18
+ // The repo-identity + owner-credential leaf (task 1003081). Carved OUT of this file so
19
+ // upgrade.js can reach these three without pulling provision*.js into the merge
20
+ // resolver's executed closure; re-exported below so every existing caller is unchanged.
21
+ const { instanceRepoRemote, parseTargetRef, resolveOwnerGithubToken } = require('./provision-repo-auth.js');
18
22
 
19
23
  function dbName(inst) { return inst.db_name || inst.slug; }
20
24
 
@@ -288,25 +292,10 @@ function dbRoleGrantsCmd(inst, { privileged = false } = {}) {
288
292
  // THROW if the instance repo wasn't already cloned. These generate + run the scaffold
289
293
  // that clones an EMPTY target_ref and turns it into an installable, bootable instance.
290
294
 
291
- // Parse an 'owner/repo' (or a github SSH/HTTPS URL) target_ref → { owner, name }.
292
- // Null when unparseable, so the scaffold can fail with actionable guidance.
293
- function parseTargetRef(ref) {
294
- const m = String(ref || '').trim()
295
- .replace(/^git@[^:]+:/, '')
296
- .replace(/^https?:\/\/[^/]+\//, '')
297
- .match(/^([A-Za-z0-9][A-Za-z0-9_.-]*)\/([A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/);
298
- return m ? { owner: m[1], name: m[2] } : null;
299
- }
300
-
301
- // The git remote for an instance's OWN repo. Reuses the SAME SSH deploy alias the
302
- // control-plane checkout uses (git@<alias>:<owner>/<repo>.git) — NO secret in the
303
- // command (SSH-key auth, nothing to leak into journald), consistent with how the
304
- // platform repo is wired. Standing up a new instance repo therefore needs only that
305
- // the control plane's deploy key has WRITE access to that repo (ADR 0125). Alias is
306
- // overridable via PROVISION_GIT_SSH_ALIAS.
307
- function instanceRepoRemote(repo, { sshAlias = process.env.PROVISION_GIT_SSH_ALIAS || 'github-deploy' } = {}) {
308
- return `git@${sshAlias}:${repo.owner}/${repo.name}.git`;
309
- }
295
+ // parseTargetRef + instanceRepoRemote (and resolveOwnerGithubToken, below the scaffold)
296
+ // now live in the ./provision-repo-auth.js leaf and are re-exported unchanged from here —
297
+ // upgrade.js needs them and is inside the merge resolver's executed closure, which a require of
298
+ // this file would have widened by five (task 1003081; see that module's header).
310
299
 
311
300
  // A minimal, VALID `bongos init` spec (init.validateSpec greenfield) from the
312
301
  // provisioning row + the owner's github login. The scaffold produces a bootable,
@@ -613,18 +602,20 @@ async function scaffoldStandaloneRepo(inst, deps) {
613
602
  exec(`git -C ${root} checkout -B main`);
614
603
  }
615
604
 
616
- // Core-install path (ADR 0134, task 2090). DEFAULT = vendor a tarball built from THIS
617
- // control-plane checkout (offline, proven — task 2053). OPT-IN = pull the PRIVATE
618
- // @bongos/core from the npm registry (PROVISION_CORE_FROM_REGISTRY=1). The registry
619
- // path needs NPM_TOKEN at `npm ci` time; if the flag is set but no token is present we
620
- // FAIL SOFT to the vendored path so a tokenless standup still works (never a broken
621
- // instance). The env is injectable for tests; the runner falls back to process.env.
622
- const env = deps.env || process.env;
623
- const wantRegistry = registryCoreRequested(env);
624
- const useRegistry = wantRegistry && hasNpmToken(env);
625
- if (wantRegistry && !useRegistry) {
626
- log(' [scaffold] PROVISION_CORE_FROM_REGISTRY set but no NPM_TOKEN in env — falling back to the vendored-tarball core (ADR 0134 fail-soft).');
627
- }
605
+ // Core-install path: vendor a tarball built from THIS control-plane checkout
606
+ // (offline, proven — task 2053).
607
+ //
608
+ // This used to be a fork: an opt-in env var selected the registry, and a missing npm
609
+ // credential silently failed it soft back to vendored. Both halves were auth-era and both
610
+ // are gone (task 1003878) — @bongos/core is public, so nothing needs a credential, and an
611
+ // env var that silently changed which install path ran is the divergence behind task
612
+ // 1003520. tests/provision.mjs pins both retired names and proves they are inert.
613
+ //
614
+ // Vendored stays the default here for a reason that has nothing to do with auth and
615
+ // survives the package going public: THIS CHECKOUT IS ROUTINELY AHEAD OF THE REGISTRY.
616
+ // A control plane on an unreleased CORE_VERSION has no published version to install, so
617
+ // the registry path would 404 on exactly the commits an operator most wants to stand up.
618
+ // Whether the vendored path still earns its keep at all is gate 6's call (task 1003879).
628
619
 
629
620
  // materialize config/ + .claude/ + a pinned package.json + lockfile via `bongos init`.
630
621
  // --no-seed: the instance's own DB is seeded by migrate, not here (and on the adopt path
@@ -638,27 +629,21 @@ async function scaffoldStandaloneRepo(inst, deps) {
638
629
  // BLOCKING pre-flight finding still hard-stops — it cannot be waved through). init refuses
639
630
  // --adopt together with --force, so the two flag sets are mutually exclusive by design.
640
631
  //
641
- // Two mutually exclusive core-resolution shapes, orthogonal to the mode:
642
- // registry (--registry-core): caret pin + env-fed .npmrc; `npm ci` pulls from the
643
- // registry. init generates the lockfile (NPM_TOKEN is present — we gated on it).
644
- // vendored (--vendor-core, default): a tarball built from this checkout, file:-pinned.
632
+ // Core resolution is vendored (--vendor-core): a tarball built from this checkout,
633
+ // file:-pinned. init still accepts --registry-core for an operator who explicitly wants
634
+ // a published version, and that flag no longer needs any credential.
645
635
  const modeFlags = mode === 'adopt' ? '--adopt --accept' : '--force';
646
636
  const spec = instanceInitSpec(inst, { ownerLogin });
647
637
  const specPath = path.join(os.tmpdir(), `bongos-init-${inst.slug}.json`);
648
638
  writeFile(specPath, JSON.stringify(spec, null, 2));
649
- if (useRegistry) {
650
- log(` [scaffold] core install: registry — PRIVATE @bongos/core@${coreV} via env-fed NPM_TOKEN (ADR 0134)`);
651
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --registry-core`);
652
- } else {
653
- // Vendored tarball — resolveVendorableCoreTarball's three-rung ladder (shared
654
- // with the re-provision pin refresh, task 1003050).
655
- const { tgz, packDest } = resolveVendorableCoreTarball({
656
- instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[scaffold] core install:',
657
- });
658
- exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
659
- // init --vendor-core COPIED the tarball into the child's vendor/ — the temp pack dir is spent.
660
- if (packDest) exec(`rm -rf ${packDest}`);
661
- }
639
+ // Vendored tarball — resolveVendorableCoreTarball's three-rung ladder (shared
640
+ // with the re-provision pin refresh, task 1003050).
641
+ const { tgz, packDest } = resolveVendorableCoreTarball({
642
+ instanceRoot: deps.instanceRoot, exec, log, coreV, tag: '[scaffold] core install:',
643
+ });
644
+ exec(`node scripts/gds/init.js --from ${specPath} --dir ${root} --no-seed ${modeFlags} --vendor-core ${tgz}`);
645
+ // init --vendor-core COPIED the tarball into the child's vendor/ — the temp pack dir is spent.
646
+ if (packDest) exec(`rm -rf ${packDest}`);
662
647
 
663
648
  // 4. commit + push to the instance's own main (keep node_modules out).
664
649
  //
@@ -711,24 +696,6 @@ async function scaffoldStandaloneRepo(inst, deps) {
711
696
 
712
697
  // ── private-repo deploy key (ADR 0176, task 1002735; walk finding N4) ────────────
713
698
 
714
- // The instance owner's stored `repo`-scoped OAuth token (ADR 0155), or null (none
715
- // stored, org-owned repo, secret-box off). Shared by the scaffold push, the
716
- // private-repo deploy-key wiring, and the teardown revoke. `deps.getGithubToken` is
717
- // injectable for tests; production resolves it lazily against the core db slice
718
- // with the runner's pool.
719
- async function resolveOwnerGithubToken(inst, deps, { tag = '[repo-auth]' } = {}) {
720
- if (!inst.owner_builder_id) return null;
721
- try {
722
- const fetchTok = deps.getGithubToken
723
- || ((bid) => require(path.join(REPO_ROOT, 'src/bongos/db')).getGithubToken({ builderId: bid, p: deps.db }));
724
- const r = await fetchTok(inst.owner_builder_id);
725
- return r && r.token ? r.token : null;
726
- } catch (e) {
727
- (deps.log || (() => {}))(` ${tag} owner OAuth token lookup failed (${e && e.message})`);
728
- return null;
729
- }
730
- }
731
-
732
699
  function deployKeyTitle(inst) { return `cloudbongos-deploy-${inst.slug}`; }
733
700
  // Beside the checkout's own metadata: untracked by definition, survives the deploy's
734
701
  // `git reset --hard`, and dies with the checkout rather than accreting in ~/.ssh.
@@ -360,11 +360,9 @@ async function provisionInstance(inst, deps) {
360
360
  // repo + install the pinned @bongos/core, so the migrate + service below run
361
361
  // from the INSTANCE root, not this control-plane checkout. cwd = standaloneRoot.
362
362
  // boxExec = the control plane (where scaffoldStandaloneRepo just created the dir).
363
- // REGISTRY CORE (ADR 0134): when the scaffold pinned the registry core, the committed
364
- // .npmrc reads `_authToken=${NPM_TOKEN}` — so `npm ci` resolves the PRIVATE package
365
- // only if NPM_TOKEN is in this runner's env (makeExec inherits process.env). The
366
- // scaffold already gated on the token, so it is present; the VENDORED default needs
367
- // no token (the tarball is committed). A tokenless registry `npm ci` 404s cleanly.
363
+ // CORE RESOLUTION: the vendored default needs no network at all (the tarball is
364
+ // committed). A registry pin resolves from the public registry — @bongos/core is public
365
+ // (task 1003872), so `npm ci` needs no credential either way (task 1003878).
368
366
  const root = standaloneRoot(inst);
369
367
  log(' [code] standalone — pull instance repo + npm ci (pinned @bongos/core)');
370
368
  boxExec(standalonePullCmd(), { cwd: root });
@@ -587,7 +587,7 @@ function instanceNotRunningCard({ origin, depsMissing } = {}) {
587
587
  out.push(`${surface.strong(`Don't run ${surface.code('bongos dev')} for this.`)} It stands up a separate local instance with its own empty database — it cannot show you this board, and it would hide the outage behind an empty one.`);
588
588
  if (depsMissing) {
589
589
  out.push('');
590
- out.push(`(The ${surface.code('bongos')} CLI isn't installed here. It's optional — the website above is the same board. ${surface.code('@bongos/core')} is a ${surface.strong('private')} package, so installing it needs an ${surface.code('NPM_TOKEN')}; a 404 without one is expected, not a missing version.)`);
590
+ out.push(`(The ${surface.code('bongos')} CLI isn't installed here. It's optional — the website above is the same board. ${surface.code('@bongos/core')} is ${surface.strong('public')} on npm, so ${surface.code('npm install @bongos/core')} needs no token or login; a failure here is a real error, not the expected tokenless 404 it used to be.)`);
591
591
  }
592
592
  return out.join('\n');
593
593
  }
@@ -122,9 +122,9 @@ function resolveChannelTarget({ installed, channel, available } = {}) {
122
122
 
123
123
  // ---- registry: what versions of the core are published? ---------------------
124
124
 
125
- // List published versions of @bongos/core from the registry via `npm view … versions --json`
126
- // (ADR 0134 — the private scoped package resolves through the instance's env-fed .npmrc, so run this
127
- // with cwd inside an instance that has one + NPM_TOKEN in env). Fail-SOFT: any error returns
125
+ // List published versions of @bongos/core from the registry via `npm view … versions --json`.
126
+ // The package is public (task 1003872), so this needs no credential and no instance-local
127
+ // `.npmrc` — any cwd resolves it. Fail-SOFT: any error returns
128
128
  // { ok:false } so the routine skips that instance rather than crashing the whole sweep. `run` +
129
129
  // `pkg` are injectable for tests.
130
130
  function listAvailableVersions({ cwd = process.cwd(), pkg = CORE_PKG, run = spawnSync, env = process.env } = {}) {
@@ -27,10 +27,11 @@
27
27
  // against `--instance <consumer>` — see docs/recipes for the bootstrap note.
28
28
 
29
29
  const fs = require('node:fs');
30
+ const os = require('node:os');
30
31
  const path = require('node:path');
32
+ const crypto = require('node:crypto');
31
33
  const { spawnSync } = require('node:child_process');
32
34
  const { arg, hasFlag } = require('./cli-lib');
33
- const { writeNpmrc, hasNpmToken, resolveNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc; resolveNpmToken task 1002754
34
35
 
35
36
  const CORE_PKG = '@bongos/core';
36
37
  const ARTIFACT = 'bongos-core';
@@ -96,9 +97,9 @@ function writePin(instanceDir, vendorRel, fsImpl = fs) {
96
97
  return prev;
97
98
  }
98
99
 
99
- // Rewrite the consumer package.json to pin an EXACT registry version (ADR 0134, task
100
- // 2090): the registry sibling of writePin. `npm install` then resolves @bongos/core@version
101
- // from the registry (auth via the env-fed .npmrc). Returns the previous dep string.
100
+ // Rewrite the consumer package.json to pin an EXACT registry version: the registry sibling
101
+ // of writePin. `npm install` then resolves @bongos/core@version from the public registry —
102
+ // no credential, since the package is public (task 1003872). Returns the previous dep string.
102
103
  function writeRegistryPin(instanceDir, version, fsImpl = fs) {
103
104
  const pkgPath = path.join(instanceDir, 'package.json');
104
105
  const pkg = readJson(pkgPath, fsImpl);
@@ -170,18 +171,133 @@ function dirtyPinFiles(instanceDir, run = spawnSync) {
170
171
  return (r.stdout || '').split('\n').map((l) => l.slice(3).trim()).filter(Boolean);
171
172
  }
172
173
 
174
+ // ---- which credential the pin push rides (task 1003081, ADR 0176 follow-up) ----
175
+ //
176
+ // THE BUG THIS EXISTS FOR. A PRIVATE standalone instance's `origin` authenticates with
177
+ // a per-repo deploy key that GitHub registers READ-ONLY (ADR 0176): it can pull, never
178
+ // push. The push leg below went `git push origin HEAD:main` regardless, so on such an
179
+ // instance GitHub refused the pin push, the bump was not durable, and the next
180
+ // pull-deploy `git reset --hard origin/main` + `npm ci` reinstalled the OLD core
181
+ // against an already-migrated DB — the exact silent rollback task 1002712 closed for
182
+ // every other path, back through a warning line nobody read. The scaffold push and the
183
+ // re-provision pin refresh already prefer the owner's stored `repo`-scoped OAuth token
184
+ // over the SSH origin (provision.js resolveOwnerGithubToken + buildCorePinRefreshCommit);
185
+ // the pin push now has the same preference — the token when one is stored, the git
186
+ // remote otherwise — and a refused push is LOUD, naming the read-only key and the fix.
187
+
188
+ // The GitHub owner/name behind `remote`, or null when the remote is not a GitHub
189
+ // repository (there is nothing to push a GitHub token at) or cannot be read. Accepts
190
+ // the plain github.com forms and the shared SSH alias instanceRepoRemote wires
191
+ // (`git@github-deploy:owner/repo.git`) — never another host, so a stored token is
192
+ // never pointed at github.com/<owner>/<repo> for a repo that lives elsewhere.
193
+ function remoteGithubRepo(instanceDir, remote = 'origin', run = spawnSync) {
194
+ const r = run('git', ['remote', 'get-url', remote], { cwd: instanceDir, encoding: 'utf8' });
195
+ if (!r || r.error || r.status !== 0) return null;
196
+ const url = String(r.stdout || '').trim();
197
+ let auth;
198
+ try { auth = require('./provision-repo-auth'); } catch { return null; } // a package without the provisioner has no token path
199
+ const repo = auth.parseTargetRef(url);
200
+ if (!repo) return null;
201
+ const host = (url.match(/^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?([^/:]+)[/:]/i) || [])[1] || '';
202
+ if (host.toLowerCase() !== 'github.com' && url !== auth.instanceRepoRemote(repo)) return null;
203
+ return repo;
204
+ }
205
+
206
+ // The deploy key `remote` authenticates with when the checkout is wired the ADR 0176
207
+ // way (`core.sshCommand … -i <key>` in the repo's own config); null when it is not.
208
+ // What a refused origin push is diagnosed against — GitHub's SSH refusal is a terse
209
+ // one-liner that names no key.
210
+ function pinnedDeployKey(instanceDir, run = spawnSync) {
211
+ const r = run('git', ['config', '--get', 'core.sshCommand'], { cwd: instanceDir, encoding: 'utf8' });
212
+ if (!r || r.error || r.status !== 0) return null;
213
+ const m = String(r.stdout || '').match(/(?:^|\s)-i\s+(\S+)/);
214
+ return m ? m[1] : null;
215
+ }
216
+
217
+ // Resolve { token, repo } for the pin push, or null → push via the git remote.
218
+ // repo — the remote's GitHub owner/name (remoteGithubRepo);
219
+ // row — provision-repo-auth lookupInstanceRow, used ONLY when its target_ref IS that
220
+ // repo, so another instance's credential is never spent on this checkout;
221
+ // token — provision-repo-auth resolveOwnerGithubToken (deps.getGithubToken / deps.db
222
+ // are its test seams), the same resolver the scaffold push uses.
223
+ // Every miss is the origin path, not an error, and the token is never logged. The row
224
+ // lookup and the token read BOTH live in the leaf rather than here because their default
225
+ // requires are computed, and this file is inside the merge resolver's executed closure
226
+ // with a pinned computed-require count of its own (task 1003081).
227
+ async function resolvePinPushAuth({ instanceDir, remote = 'origin' }, deps = {}, run = spawnSync) {
228
+ const log = deps.log || console.log;
229
+ const repo = remoteGithubRepo(instanceDir, remote, run);
230
+ if (!repo) return null;
231
+ const auth = require('./provision-repo-auth');
232
+ const inst = await auth.lookupInstanceRow(instanceDir, deps);
233
+ if (!inst) return null;
234
+ const rowRepo = auth.parseTargetRef(inst.target_ref);
235
+ const same = (a, b) => a && b && a.owner.toLowerCase() === b.owner.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
236
+ if (!same(rowRepo, repo)) {
237
+ log(` • pin push: the provisioning row for ${path.basename(instanceDir)} names ${inst.target_ref}, not ${remote}'s ${repo.owner}/${repo.name} — its owner's token is not for this checkout; pushing via ${remote}`);
238
+ return null;
239
+ }
240
+ const token = await auth.resolveOwnerGithubToken(inst, { ...deps, log }, { tag: '[pin-push]' });
241
+ if (!token) {
242
+ log(` • pin push: no owner GitHub token stored for ${repo.owner}/${repo.name} — pushing via ${remote}`);
243
+ return null;
244
+ }
245
+ return { token, repo };
246
+ }
247
+
248
+ // The pin push over the owner's token: a chmod-600 credential-store file in the temp
249
+ // dir carries the secret, git reads it through `credential.helper=store`, and the file
250
+ // is removed in `finally` whatever the push did — the token is never in argv (ps,
251
+ // journald) and never in a log line. provision.js buildCorePinRefreshCommit's shape,
252
+ // as argv instead of a shell string.
253
+ function pushPinViaToken({ instanceDir, token, repo, target }, run = spawnSync) {
254
+ const credFile = path.join(os.tmpdir(), `bongos-gitcred-pin-${process.pid}-${crypto.randomBytes(6).toString('hex')}`);
255
+ try {
256
+ fs.writeFileSync(credFile, `https://x-access-token:${token}@github.com\n`, { mode: 0o600 });
257
+ return run('git', ['-c', `credential.helper=store --file=${credFile}`, 'push', `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`, target], {
258
+ cwd: instanceDir, encoding: 'utf8',
259
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }, // a rejected credential must fail, not sit at a password prompt
260
+ });
261
+ } finally {
262
+ fs.rmSync(credFile, { force: true });
263
+ }
264
+ }
265
+
266
+ // What git said on stderr, for a refusal line — GitHub's own reason ("ERROR: The key you
267
+ // are authenticating with has been marked as read only", "remote: Write access to
268
+ // repository not granted") is the fact an operator wants beside the diagnosis, and it
269
+ // is never the LAST line: git closes with its generic `fatal:`. Prefer the server's
270
+ // line, fall back to the last one.
271
+ function gitSaid(r) {
272
+ const lines = String((r && r.stderr) || '').split('\n').map((l) => l.trim()).filter(Boolean);
273
+ const server = lines.filter((l) => /^(ERROR:|remote:)/i.test(l));
274
+ const said = server.length ? server[server.length - 1] : lines[lines.length - 1];
275
+ return said ? ` (git: ${said})` : '';
276
+ }
277
+
173
278
  // `push: false` is CO-TENANT MODE (task 1003521). The reasoning above assumes the repo
174
279
  // of record is OURS; on a provisioned instance it is the customer's own GitHub repo, so
175
280
  // pushing there is both wrong and uncredentialed. Such an instance also has no
176
281
  // pull-deploy timer, so nothing will `reset --hard` the pin away — the commit alone
177
282
  // makes it durable, and leaves the tree clean for the customer's own pulls.
178
- function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false },
283
+ //
284
+ // `pushAuth` ({ token, repo }, from resolvePinPushAuth) makes the push ride the owner's
285
+ // GitHub token over HTTPS (task 1003081); null pushes via `remote`. The result carries
286
+ // `via: 'owner-token'` on the token path and `refused` ('read-only-deploy-key' |
287
+ // 'owner-token') with a plain-sentence `error` when GitHub turned the push away.
288
+ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = true, remote = 'origin', dryRun = false, pushAuth = null },
179
289
  run = spawnSync, log = console.log, err = console.error) {
180
290
  const dirty = dirtyPinFiles(instanceDir, run);
181
291
  if (dirty === null) return { applicable: false, reason: 'not a git repo' };
182
292
  if (!dirty.length) return { applicable: false, reason: 'pin files already match the repo' };
183
293
 
184
294
  const branch = gitCurrentBranch(instanceDir, run);
295
+ const target = `HEAD:${branch || 'main'}`;
296
+ // Single-quote the interpolated values. Nothing here executes these strings — they
297
+ // are printed for a human to paste — but an unquoted branch or remote carrying shell
298
+ // metacharacters would misbehave once pasted, so emit them paste-safe.
299
+ const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
300
+ const at = `git -C ${q(instanceDir)}`;
185
301
 
186
302
  if (!commitPin) {
187
303
  if (!push) {
@@ -198,12 +314,7 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
198
314
  err(' On a pull-deploy instance the next push to the repo of record makes deploy.sh');
199
315
  err(` 'git reset --hard ${remote}/${branch || 'main'}', which REVERTS this pin and lets npm ci`);
200
316
  err(` reinstall ${fromVersion || 'the previous core'}. Re-run with --commit-pin, or commit + push:`);
201
- // Single-quote the interpolated values. Nothing here executes this string — it is
202
- // printed for a human to paste — but an unquoted branch or remote carrying shell
203
- // metacharacters would misbehave once pasted, so emit it paste-safe.
204
- const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
205
- const at = `git -C ${q(instanceDir)}`;
206
- err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(`HEAD:${branch || 'main'}`)}`);
317
+ err(` ${at} add ${PIN_FILES.map(q).join(' ')} && ${at} commit -m ${q(`pin @bongos/core ${toVersion}`)} && ${at} push ${q(remote)} ${q(target)}`);
207
318
  return { applicable: true, committed: false, pushed: false, dirty, warned: true };
208
319
  }
209
320
 
@@ -232,11 +343,62 @@ function persistPin({ instanceDir, fromVersion, toVersion, commitPin, push = tru
232
343
  return { applicable: true, committed: true, pushed: false, dirty, pushSkipped: true };
233
344
  }
234
345
 
235
- const pushed = run('git', ['push', remote, `HEAD:${branch || 'main'}`], { cwd: instanceDir, encoding: 'utf8' });
346
+ // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE. So every
347
+ // refusal below is LOUD (task 1003081) — the pull-deploy consequence, the diagnosis,
348
+ // and a by-hand push that can actually write.
349
+ const revert = () => err(` The next pull-deploy 'git reset --hard ${remote}/${branch || 'main'}' + npm ci then reinstalls ${fromVersion || 'the previous core'}.`);
350
+
351
+ if (pushAuth && pushAuth.token && pushAuth.repo) {
352
+ const { repo } = pushAuth;
353
+ let pushed = null;
354
+ try {
355
+ pushed = pushPinViaToken({ instanceDir, token: pushAuth.token, repo, target }, run);
356
+ } catch (e) {
357
+ // The credential file could not even be written (an unwritable temp dir): say so
358
+ // and fall through to the remote — a public origin may still take it.
359
+ err(` ! could not stage the owner's GitHub credential (${e && e.message}) — pushing via ${remote} instead`);
360
+ }
361
+ if (pushed && !pushed.error && pushed.status === 0) {
362
+ log(` ✓ pushed the pin to ${repo.owner}/${repo.name}@${branch || 'main'} via the owner's GitHub token — a pull-deploy reset can no longer revert it`);
363
+ return { applicable: true, committed: true, pushed: true, dirty, via: 'owner-token' };
364
+ }
365
+ if (pushed) {
366
+ err(` ! PIN NOT PUSHED — GitHub refused the push over the owner's GitHub token, so this bump is NOT durable.`);
367
+ err(` The stored token could not write ${repo.owner}/${repo.name}: it may have expired (they live an hour), or`);
368
+ err(` lack the \`repo\` scope (\`public_repo\` cannot write a private repository — ADR 0155).${gitSaid(pushed)}`);
369
+ revert();
370
+ err(' Fix: have the instance owner reconnect GitHub with repository access, then re-run `bongos upgrade --commit-pin`;');
371
+ err(' or push the commit yourself with a credential that can write:');
372
+ err(` ${at} push ${q(`https://github.com/${repo.owner}/${repo.name}.git`)} ${q(target)}`);
373
+ return {
374
+ applicable: true, committed: true, pushed: false, dirty, refused: 'owner-token',
375
+ error: `GitHub refused the pin push over the owner's GitHub token (expired, or missing the repo scope) — the pin is committed locally but not on ${repo.owner}/${repo.name}`,
376
+ };
377
+ }
378
+ }
379
+
380
+ const pushed = run('git', ['push', remote, target], { cwd: instanceDir, encoding: 'utf8' });
236
381
  if (!pushed || pushed.error || pushed.status !== 0) {
237
- // Committed but not pushed is still doomed: deploy.sh resets to the REMOTE.
382
+ const key = pinnedDeployKey(instanceDir, run);
383
+ if (key) {
384
+ // A wired private instance (ADR 0176): the key can pull, never push, and no owner
385
+ // token was stored to push with instead — the bug this leg exists for.
386
+ const repo = remoteGithubRepo(instanceDir, remote, run);
387
+ err(` ! PIN NOT PUSHED — GitHub refused the push to ${remote}, so this bump is NOT durable.`);
388
+ err(` ${remote} authenticates with the deploy key at ${key}, which is registered READ-ONLY on the`);
389
+ err(` repository (ADR 0176): it can pull, never push. No owner GitHub token is stored to push with instead.${gitSaid(pushed)}`);
390
+ revert();
391
+ err(' Fix: have the instance owner reconnect GitHub with repository access (that stores a short-lived repo token),');
392
+ err(` then re-run \`bongos upgrade --commit-pin\` where that token is readable — the control plane, with --instance ${q(instanceDir)};`);
393
+ err(' or push the commit yourself with a credential that can write:');
394
+ err(` ${at} push ${q(`https://github.com/${repo ? `${repo.owner}/${repo.name}` : '<owner>/<repo>'}.git`)} ${q(target)}`);
395
+ return {
396
+ applicable: true, committed: true, pushed: false, dirty, refused: 'read-only-deploy-key',
397
+ error: `GitHub refused the pin push: ${remote} authenticates with the read-only deploy key at ${key}, and no owner GitHub token is stored to push with — the pin is committed locally but not on the remote`,
398
+ };
399
+ }
238
400
  err(` ! commit landed locally but the push to ${remote} FAILED — this bump is still not durable.`);
239
- err(` Push it: git -C ${instanceDir} push ${remote} HEAD:${branch || 'main'}`);
401
+ err(` Push it: git -C ${instanceDir} push ${remote} ${target}`);
240
402
  return { applicable: true, committed: true, pushed: false, dirty, error: 'git push failed' };
241
403
  }
242
404
  log(` ✓ pushed the pin to ${remote}/${branch || 'main'} — a pull-deploy reset can no longer revert it`);
@@ -315,13 +477,13 @@ function reportModulePreflight(mods, targetVersion, { log = console.log, err = c
315
477
  return lines;
316
478
  }
317
479
 
318
- function npmInstall(instanceDir, run = spawnSync, extraEnv = null) {
480
+ function npmInstall(instanceDir, run = spawnSync) {
319
481
  // `npm install` (NOT `npm ci`): the pin just changed, so the lockfile must be regenerated from the
320
482
  // new tarball; `npm ci` would refuse on the package.json↔lock divergence.
321
- // extraEnv (task 1002754): the registry path injects a resolved NPM_TOKEN here so the
322
- // instance `.npmrc`'s `${NPM_TOKEN}` expansion never shadows a valid user-level token.
483
+ // This used to take an extraEnv carrying a resolved npm credential, because the instance
484
+ // `.npmrc`'s env expansion could shadow a valid user-level one (task 1002754). The core is
485
+ // public and writes no `.npmrc`, so there is no credential to resolve (task 1003878).
323
486
  const opts = { cwd: instanceDir, stdio: 'inherit' };
324
- if (extraEnv) opts.env = { ...process.env, ...extraEnv };
325
487
  const r = run('npm', ['install', '--no-audit', '--no-fund'], opts);
326
488
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
327
489
  }
@@ -820,11 +982,8 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
820
982
  try { restore(instanceDir, snapshot.prevPin, fsImpl); result.restoreOk = true; log(` ✓ restored pin → ${snapshot.prevPin}`); }
821
983
  catch (e) { result.restoreOk = false; err(` ! could not restore pin (${e.message}) — package.json may need manual repair`); }
822
984
 
823
- // 2. reinstall the previous core. Same credential resolution as the forward path
824
- // (task 1002754): the previous pin may be a registry pin too, and a rollback
825
- // must never fail on the env-shadowing trap the forward install just avoided.
826
- const rbCred = resolveNpmToken();
827
- const inst = npmInstall(instanceDir, run, rbCred.source === 'user-npmrc' ? { NPM_TOKEN: rbCred.token } : null);
985
+ // 2. reinstall the previous core.
986
+ const inst = npmInstall(instanceDir, run);
828
987
  result.reinstallOk = !!inst.ok;
829
988
  if (inst.ok) log(' ✓ reinstalled previous core');
830
989
  else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
@@ -931,24 +1090,16 @@ async function runUpgrade(opts, deps = {}) {
931
1090
  return { ok: false, error: 'post-restart health check is mandatory for a bump that restarts the service, but no --health-url was given.\n Pass --health-url <url> to confirm the new core serves after restart, or --no-health-check to explicitly skip the confirmation.' };
932
1091
  }
933
1092
 
934
- // 2. bump the pin. Two mutually exclusive core sources (ADR 0134, task 2090):
935
- // REGISTRY (--registry): pin @bongos/core@<version> + ensure the env-fed .npmrc, so
936
- // the `npm install` below pulls the PRIVATE package from the registry (needs NPM_TOKEN).
1093
+ // 2. bump the pin. Two mutually exclusive core sources:
1094
+ // REGISTRY (--registry): pin @bongos/core@<version> and let `npm install` pull it from
1095
+ // the public registry. No credential — the package is public (task 1003872), which
1096
+ // also retired the whole resolve-a-token-and-inject-it dance (task 1002754/1003878).
937
1097
  // VENDORED (default, --from <tgz>): copy the .tgz into vendor/ + rewrite the file: dep.
938
- let installEnv = null;
939
1098
  if (opts.registry) {
940
1099
  if (opts.from) return { ok: false, error: '--registry and --from are mutually exclusive (registry pull vs vendored tarball) — pick one core source' };
941
- // task 1002754 — resolve the credential HERE instead of demanding a hand-exported
942
- // env var: env NPM_TOKEN wins, else the user-level ~/.npmrc's literal token is
943
- // injected into the install's env so the instance `.npmrc`'s `${NPM_TOKEN}`
944
- // expansion (which OUTRANKS the user-level key and expands EMPTY when unset)
945
- // can never shadow a working credential again.
946
- const cred = resolveNpmToken();
947
- if (!cred.token) log(' ! no NPM_TOKEN in env and no literal token in ~/.npmrc — `npm install` will 404 on the PRIVATE @bongos/core until a read-only automation token is provided (ADR 0134).');
948
- else if (cred.source === 'user-npmrc') { installEnv = { NPM_TOKEN: cred.token }; log(' ✓ registry credential: inherited from the user-level ~/.npmrc (no NPM_TOKEN export needed — task 1002754)'); }
949
1100
  const prevPin = dryRun ? null : writeRegistryPin(instanceDir, targetVersion, fsImpl);
950
- if (!dryRun) { writeNpmrc(instanceDir, { fsImpl, overwrite: false, log }); snapshot = { prevPin, prevVersion: fromVersion }; }
951
- log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${dryRun ? ' + write an env-fed .npmrc' : ''}${prevPin ? ` (was ${prevPin})` : ''}`);
1101
+ if (!dryRun) { snapshot = { prevPin, prevVersion: fromVersion }; }
1102
+ log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${prevPin ? ` (was ${prevPin})` : ''}`);
952
1103
  } else {
953
1104
  let vendorRel = opts.pinPath || (opts.from ? null : vendorRelFor(targetVersion));
954
1105
  if (opts.from) {
@@ -967,7 +1118,7 @@ async function runUpgrade(opts, deps = {}) {
967
1118
  if (dryRun) { log(' [dry-run] would: npm install → npm run migrate → materialize .claude → regen API docs+client → restart → verify'); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
968
1119
 
969
1120
  // 3. npm install.
970
- const inst = npmInstall(instanceDir, run, installEnv);
1121
+ const inst = npmInstall(instanceDir, run);
971
1122
  if (!inst.ok) {
972
1123
  const error = `npm install failed (${inst.reason || 'exit ' + inst.code})`;
973
1124
  if (rollbackOnFailure && snapshot) {
@@ -1144,14 +1295,23 @@ async function runUpgrade(opts, deps = {}) {
1144
1295
 
1145
1296
  // Make the pin durable (task 1002712). Runs LAST — only a verified-healthy
1146
1297
  // upgrade is worth persisting, and a rolled-back one returned long before here.
1298
+ //
1299
+ // Which credential the push rides is resolved HERE (async — it may read the token
1300
+ // store) and handed to persistPin, which stays synchronous — and only when a push is
1301
+ // actually coming: --commit-pin without --pin-no-push, not a dry run, and the pin
1302
+ // files really are dirty. Nothing reads a credential it will not spend (task 1003081).
1303
+ const remote = opts.gitRemote || 'origin';
1304
+ const pushComing = !!opts.commitPin && !opts.pinNoPush && !dryRun && (dirtyPinFiles(instanceDir, run) || []).length > 0;
1305
+ const pushAuth = pushComing ? await (deps.resolvePinPushAuth || resolvePinPushAuth)({ instanceDir, remote }, deps, run) : null;
1147
1306
  const pin = persistPin({
1148
1307
  instanceDir,
1149
1308
  fromVersion,
1150
1309
  toVersion: targetVersion,
1151
1310
  commitPin: opts.commitPin,
1152
1311
  push: !opts.pinNoPush,
1153
- remote: opts.gitRemote || 'origin',
1312
+ remote,
1154
1313
  dryRun: opts.dryRun,
1314
+ pushAuth,
1155
1315
  }, run, log, err);
1156
1316
 
1157
1317
  const ok = versionOk && healthOk !== false && servedOk !== false;
@@ -1198,7 +1358,7 @@ async function main(argv = process.argv.slice(2)) {
1198
1358
  '',
1199
1359
  ' --to <version> target core version — presence switches from pre-check to a real bump',
1200
1360
  ' --from <tgz> source tarball to vendor into vendor/ (else it must already be there)',
1201
- ' --registry pull @bongos/core@<--to> from the npm registry (env-fed .npmrc + NPM_TOKEN) instead of vendoring a tarball (ADR 0134); mutually exclusive with --from',
1361
+ ' --registry pull @bongos/core@<--to> from the public npm registry (no token needed) instead of vendoring a tarball; mutually exclusive with --from',
1202
1362
  ' --instance <dir> consumer instance root (default: cwd)',
1203
1363
  ' --service <unit> systemd unit to restart after applying',
1204
1364
  ' --restart-cmd <cmd> explicit restart command (overrides --service)',
@@ -1211,6 +1371,7 @@ async function main(argv = process.argv.slice(2)) {
1211
1371
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
1212
1372
  ' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
1213
1373
  ' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
1374
+ ' --commit-pin commit + push package.json/-lock so a pull-deploy reset cannot revert the bump. The push rides the instance owner\'s stored GitHub token when one is stored (a PRIVATE instance\'s origin is a read-only deploy key — ADR 0176), else the git remote (--git-remote <name>, default origin); a refused push is reported loudly with the fix',
1214
1375
  ' --pin-no-push with --commit-pin: commit the pin but do NOT push it — for an instance whose repo of record is not ours (a provisioned co-tenant), where the push is wrong and no pull-deploy can revert the commit',
1215
1376
  ' --dry-run show the plan; change nothing',
1216
1377
  ' --force skip the clean-tree + same-version guards',
@@ -1233,7 +1394,9 @@ async function main(argv = process.argv.slice(2)) {
1233
1394
  module.exports = {
1234
1395
  runUpgrade, parseArgs, main, rollback,
1235
1396
  versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
1236
- vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity, resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1397
+ vendorTarball, gitTreeClean, persistPin, dirtyPinFiles, gitCurrentBranch, PIN_FILES, preflightDbIdentity,
1398
+ resolveInstanceDb, preflightModules, reportModulePreflight, npmInstall, runMigrate, regenerateApiArtifacts, regenerateNavDocs, NAV_WHOLE_FILE_GENERATORS, missingDocAssets, healDocAssets, DOC_ASSET_GENERATORS, restartService, recordLedger, pollHealth, deriveVersionUrl, pollServedVersion,
1399
+ resolvePinPushAuth, remoteGithubRepo, pinnedDeployKey, // task 1003081 — the pin push prefers the owner's GitHub token (ADR 0176 follow-up)
1237
1400
  readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
1238
1401
  CORE_PKG, ARTIFACT, vendorRelFor,
1239
1402
  };
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.736'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.738'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -97,6 +97,14 @@ test('every computed require inside the closure is one we have accounted for', (
97
97
  // floor) is lazy and inside a function.
98
98
  ['scripts/gds/package-core.js', 6], // publish-manifest/mirror-redact/secret-scrub/leak-scan-allowlist/…
99
99
  ['scripts/gds/upgrade.js', 1], // a `node -e` child, not a require in this process
100
+ // Added by task 1003081: upgrade.js's pin push needs the owner-credential helpers,
101
+ // and requiring provision.js for them would have pulled provision-config/-repo/-units
102
+ // (7/1/1 unaccounted computed requires) into the closure. This LEAF is the whole of
103
+ // that growth: 3 computed requires — src/bongos/db in resolveOwnerGithubToken, and
104
+ // modules/provisioning + src/bongos/pool in lookupInstanceRow — each inside a function
105
+ // body reached only from the pin push, which no generator calls. Its top level requires
106
+ // node builtins only, so it drags nothing else in behind it.
107
+ ['scripts/gds/provision-repo-auth.js', 3],
100
108
  ['scripts/gds/gen-api-docs.js', 1], // require(path.join(CORE_ROOT,'package.json')) — JSON
101
109
  ]);
102
110