@bongos/core 1.24.69 → 1.24.71

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.
@@ -11,7 +11,7 @@
11
11
 
12
12
  const fs = require('node:fs');
13
13
  const path = require('node:path');
14
- const { resolveCoreRoot, resolveInstanceRoot } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
14
+ const { resolveCoreRoot, resolveInstanceRoot, resolveInstanceRootExplicit } = require('../../src/instance-config'); // ADR 0108 §1: configurable-root
15
15
 
16
16
 
17
17
  // ADR 0108 §Consequences #4 (configurable-root W6 / task 1886) — the guardrail walks TWO trees.
@@ -97,6 +97,23 @@ function isInstalledCore(root = CORE_ROOT) {
97
97
  } catch { return true; } // a package with no repo around it at all
98
98
  }
99
99
 
100
+ // resolveWorkRoot — the tree a repo-wide tool (docs-entropy, ship-check) works on (task 1005100).
101
+ // A core CHECKOUT works on itself, whatever the cwd, exactly as path.resolve(__dirname,'..','..')
102
+ // always did. An INSTALLED core's package dir is not the project — `git ls-files` there finds
103
+ // nothing and the generators read the package's own files — so it works on the project instead:
104
+ // an explicit pointer (<PREFIX>_INSTANCE_ROOT / bongos.config.json), else the git top of the cwd
105
+ // (`npx bongos exec` runs from the project), else the cwd.
106
+ function gitTopOf(cwd) {
107
+ try {
108
+ return require('node:child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || null;
109
+ } catch { return null; }
110
+ }
111
+ function resolveWorkRoot({ installed, cwd = process.cwd(), explicit = resolveInstanceRootExplicit, gitTop = gitTopOf } = {}) {
112
+ if (installed === undefined) installed = isInstalledCore();
113
+ if (!installed) return CORE_ROOT;
114
+ return explicit() || gitTop(cwd) || cwd;
115
+ }
116
+
100
117
  // runChecks — run every fitness check, or none (task 1004737). Every check guards the core's
101
118
  // own source, and an INSTALLED core's source is not the project's to change: the core repo's
102
119
  // CI ran this gate before publishing it (the same stance ship-merge-gate.js takes for a
@@ -115,6 +132,7 @@ function runChecks(checks, { installed = isInstalledCore() } = {}) {
115
132
 
116
133
  module.exports = {
117
134
  isInstalledCore,
135
+ resolveWorkRoot,
118
136
  runChecks,
119
137
  keyTokenRe,
120
138
  CORE_ROOT,
@@ -8,6 +8,8 @@
8
8
  //
9
9
  // Usage:
10
10
  // bongos paste-token <token> # what the hall hands you (task 1003961)
11
+ // bongos paste-token # no argument: asks for the token without echo,
12
+ // # or reads it from stdin when piped (task 1005033)
11
13
  // node scripts/bongos/paste-token.js <token> # equivalent, inside a core checkout
12
14
  //
13
15
  // The portal returns the exact invocation as the "paste_command" field, so a
@@ -83,10 +85,71 @@ const ARGS = parseArgs(process.argv.slice(2));
83
85
  const RESOLVED = resolveBaseForPaste({ flag: ARGS.apiBase });
84
86
  const API_BASE = RESOLVED.base;
85
87
 
88
+ // READ THE TOKEN WITHOUT PUTTING IT ON THE COMMAND LINE (task 1005033). A token passed
89
+ // as an argument lands in shell history. With no argument we ask for it: on a terminal
90
+ // the read is raw and nothing is echoed (a 64-char secret has no business on screen
91
+ // either); piped (`... | bongos paste-token`) we take the first word of stdin. The
92
+ // argument form still works — the hall's one-liner uses it, and older instances hand
93
+ // out nothing else. Exported so the no-echo read is tested, not assumed.
94
+ function readTokenFromInput({ input = process.stdin, output = process.stderr } = {}) {
95
+ if (!input.isTTY) {
96
+ // Piped: settle on the FIRST line, not at end of stream — a pipe that never closes
97
+ // must not hang the paste, and nothing after the token is ours to buffer.
98
+ return new Promise((resolve, reject) => {
99
+ let data = '';
100
+ let settled = false;
101
+ const settle = () => {
102
+ if (settled) return;
103
+ settled = true;
104
+ input.removeListener('data', onData);
105
+ input.pause();
106
+ resolve(data.trim().split(/\s+/)[0] || '');
107
+ };
108
+ function onData(c) {
109
+ data += c;
110
+ if (data.replace(/^\s+/, '').includes('\n')) settle();
111
+ }
112
+ input.setEncoding('utf8');
113
+ input.on('data', onData);
114
+ input.on('end', settle);
115
+ input.on('error', (err) => { if (!settled) { settled = true; reject(err); } });
116
+ });
117
+ }
118
+ output.write('Paste your CLI token (Settings → CLI Access) and press Enter: ');
119
+ return new Promise((resolve, reject) => {
120
+ let buf = '';
121
+ const raw = typeof input.setRawMode === 'function';
122
+ if (raw) input.setRawMode(true);
123
+ input.setEncoding('utf8');
124
+ input.resume();
125
+ function finish(err) {
126
+ input.removeListener('data', onData);
127
+ if (raw) input.setRawMode(false);
128
+ input.pause();
129
+ output.write('\n');
130
+ if (err) reject(err); else resolve(buf.trim());
131
+ }
132
+ function onData(chunk) {
133
+ for (const ch of String(chunk)) {
134
+ if (ch === '\r' || ch === '\n') { finish(); return; }
135
+ // Ctrl+C always cancels; Ctrl+D (end of input) cancels too unless a token is typed.
136
+ if (ch === '\u0003' || (ch === '\u0004' && !buf.trim())) { finish(new Error('cancelled — nothing was saved')); return; }
137
+ if (ch === '\u0004') { finish(); return; }
138
+ if (ch === '\u007f' || ch === '\b') { buf = buf.slice(0, -1); continue; }
139
+ buf += ch;
140
+ }
141
+ }
142
+ input.on('data', onData);
143
+ });
144
+ }
145
+
86
146
  function usage(extra) {
87
147
  if (extra) console.error(extra);
88
148
  console.error('');
89
- console.error('Usage: npx bongos paste-token <token> [--api-base <url>]');
149
+ console.error('Usage: npx bongos paste-token [<token>] [--api-base <url>]');
150
+ console.error(' Leave out <token> and it asks for it, so the token stays out of shell history.');
151
+ console.error(' Run it from inside your project folder (cd there first).');
152
+ console.error(' In Windows PowerShell type npx.cmd instead of npx.');
90
153
  console.error(' (inside a core checkout: node scripts/bongos/paste-token.js <token>)');
91
154
  console.error('');
92
155
  // Fall back to the inferred base for this hint when --api-base was the thing that
@@ -103,9 +166,17 @@ async function main() {
103
166
  usage(`--api-base must be an http(s) URL, got: ${ARGS.apiBase}`);
104
167
  process.exit(2);
105
168
  }
106
- const token = ARGS.token;
169
+ let token = ARGS.token;
170
+ if (!token) {
171
+ try {
172
+ token = await readTokenFromInput();
173
+ } catch (err) {
174
+ console.error(`paste-token: ${err.message}`);
175
+ process.exit(2);
176
+ }
177
+ }
107
178
  if (!token) {
108
- usage('No token argument provided.');
179
+ usage('No token given.');
109
180
  process.exit(2);
110
181
  }
111
182
  // Tokens are hex-encoded crypto.randomBytes(32) → 64 lowercase hex chars.
@@ -209,4 +280,4 @@ if (require.main === module) {
209
280
  });
210
281
  }
211
282
 
212
- module.exports = { parseArgs, resolveBaseForPaste };
283
+ module.exports = { parseArgs, resolveBaseForPaste, readTokenFromInput };
@@ -533,7 +533,9 @@ function installedCoreTarball({ instanceRoot, fsImpl = fs } = {}) {
533
533
  // nothing either way. The file: pin is kept for the reason in ADR 0168 — a child is born
534
534
  // on the core its parent actually runs — not for access control.) Returns { dir, version, tarballName } or null
535
535
  // (a file: pin, an uninstalled dep, or a source checkout). fs reads only → dry-run safe.
536
- function installedCorePackDir({ instanceRoot, fsImpl = fs } = {}) {
536
+ // `includeFilePins` (task 1002450): the stale-pin recovery below packs the core a
537
+ // file:-pinned hub has INSTALLED, so it must not skip the file: spec the default does.
538
+ function installedCorePackDir({ instanceRoot, fsImpl = fs, includeFilePins = false } = {}) {
537
539
  const root = instanceRoot || process.env.BONGOS_INSTANCE_ROOT || process.cwd();
538
540
  let pkg;
539
541
  try { pkg = JSON.parse(fsImpl.readFileSync(path.join(root, 'package.json'), 'utf8')); }
@@ -541,7 +543,7 @@ function installedCorePackDir({ instanceRoot, fsImpl = fs } = {}) {
541
543
  const deps = Object.assign({}, pkg.dependencies, pkg.devDependencies);
542
544
  for (const name of ['@bongos/core', '@cloudbongos/core']) {
543
545
  const spec = String(deps[name] || '').trim();
544
- if (!spec || spec.startsWith('file:')) continue; // file: pins belong to installedCoreTarball
546
+ if (!spec || (spec.startsWith('file:') && !includeFilePins)) continue; // file: pins belong to installedCoreTarball
545
547
  const dir = path.join(root, 'node_modules', ...name.split('/'));
546
548
  try {
547
549
  const installed = JSON.parse(fsImpl.readFileSync(path.join(dir, 'package.json'), 'utf8'));
@@ -568,13 +570,113 @@ function coreCheckoutHasGit({ repoRoot = REPO_ROOT, fsImpl = fs } = {}) {
568
570
  // The core version THIS control plane runs — what the ladder below would vend: the
569
571
  // pinned tarball's version, else the registry-installed package's, else the checkout's
570
572
  // CORE_VERSION. fs reads only → dry-run safe.
573
+ // A pinned tarball whose label disagrees with coreV is STALE (task 1002450): the hall runs
574
+ // coreV, so that is its running version, not the leftover tarball's label.
571
575
  function runningCoreVersion({ instanceRoot, coreV }) {
572
576
  const pinned = installedCoreTarball({ instanceRoot });
573
- if (pinned) return require('./upgrade').versionFromTgzPath(path.basename(pinned)) || coreV;
577
+ if (pinned) {
578
+ const pinnedV = pinnedTarballVersion(pinned);
579
+ return (coreV && pinnedV && pinnedV !== coreV) ? coreV : (pinnedV || coreV);
580
+ }
574
581
  const packSrc = installedCorePackDir({ instanceRoot });
575
582
  return packSrc ? packSrc.version : coreV;
576
583
  }
577
584
 
585
+ // ── the stale-pin guard (task 1002450) ────────────────────────────────────────────
586
+ // Rung 1 below trusted a file:-pinned hub's own vendored tarball unconditionally ("the
587
+ // child pins the SAME core the hub runs"). Two ways that was false, both seen live: an
588
+ // out-of-band upgrade swaps node_modules/@bongos/core without rewriting vendor/*.tgz, so
589
+ // the label trails the core the hub RUNS; and the same label can name different bytes —
590
+ // an early instance was born on a 1.18.14 tarball 15 kB smaller than the published 1.18.14,
591
+ // missing the task-1002419 poller fix, so its first ship never left 'confirmed'. Quiet
592
+ // failure, so this guard is loud.
593
+ //
594
+ // The two sibling gaps are closed elsewhere, on purpose — tests/provision.mjs proves each:
595
+ // - PUBLISHING one version twice as different bytes: the npm registry refuses it (a
596
+ // version is immutable), and .github/workflows/publish.yml fails the run loudly on
597
+ // that refusal (task 1004886). What escaped was a LOCAL build carrying a published
598
+ // label — the vendoring this guard checks.
599
+ // - a hub BEHIND upstream: projectCoreDecision gives a new project the release when
600
+ // the runner is behind it (task 1004556), and that target short-circuits this ladder.
601
+ // The integrity read costs one `npm view` on the reuse path; projectCoreTarget already
602
+ // makes one per scaffold, and both fail open when the registry is unreachable.
603
+
604
+ // The version a core tarball's FILENAME carries — both scope aliases (bongos-core-*,
605
+ // cloudbongos-core-*), which upgrade.versionFromTgzPath (bongos-core only) would miss.
606
+ // Digits, dots and [A-Za-z0-9.+-] only, so the result is shell-safe. PURE.
607
+ function pinnedTarballVersion(p) {
608
+ const m = /(?:^|-)core-(\d+\.\d+\.\d+[A-Za-z0-9.+-]*)\.tgz$/.exec(path.basename(String(p || '')));
609
+ return m ? m[1] : null;
610
+ }
611
+
612
+ // A version that may enter a shell command, or null — the same character set as
613
+ // safeVersionLabel, the file's interpolation chokepoint. PURE.
614
+ function shellSafeVersion(v) {
615
+ const s = safeVersionLabel(v);
616
+ return s === '(unversioned)' ? null : s;
617
+ }
618
+
619
+ // npm's dist.integrity form for a file: sha512, base64. fs injectable for tests.
620
+ function tarballIntegrity(p, fsImpl = fs) {
621
+ return 'sha512-' + crypto.createHash('sha512').update(fsImpl.readFileSync(p)).digest('base64');
622
+ }
623
+
624
+ // Is the pinned tarball the core this hub runs, AND the bytes the registry published
625
+ // under that label? → { verdict, pinnedV, reason }:
626
+ // 'stale-version' — its label is not coreV (the hub moved on, the tarball did not);
627
+ // 'stale-bytes' — the label matches but the registry's integrity for it differs
628
+ // (the charter case: same version, different artifact);
629
+ // 'unverifiable' — no coreV, an unparseable label, or no registry answer: reuse it
630
+ // (fail-open, never block a scaffold on a resolution gap) but SAY so;
631
+ // 'fresh' — both checks pass.
632
+ // The registry read goes through the injected exec, so a dry run never hits the network.
633
+ function checkPinnedTarball({ pinned, coreV, exec, fsImpl = fs }) {
634
+ const pinnedV = shellSafeVersion(pinnedTarballVersion(pinned));
635
+ if (!pinnedV) return { verdict: 'unverifiable', pinnedV, reason: 'no version in its filename' };
636
+ if (!coreV) return { verdict: 'unverifiable', pinnedV, reason: 'the running core version is unknown' };
637
+ if (pinnedV !== coreV) return { verdict: 'stale-version', pinnedV, reason: `it is ${pinnedV} but this control plane runs ${coreV}` };
638
+ const res = exec(`npm view ${CORE_PKG}@${pinnedV} dist.integrity`, { allowFail: true });
639
+ const published = String((res && res.ok !== false && res.stdout) || '').trim();
640
+ if (!/^sha512-/.test(published)) return { verdict: 'unverifiable', pinnedV, reason: `the registry gave no integrity for ${pinnedV}` };
641
+ let local;
642
+ try { local = tarballIntegrity(pinned, fsImpl); } catch { return { verdict: 'unverifiable', pinnedV, reason: 'it could not be read' }; }
643
+ if (local !== published) return { verdict: 'stale-bytes', pinnedV, reason: `its bytes are not the ${pinnedV} the registry published (a different build under the same version label)` };
644
+ return { verdict: 'fresh', pinnedV, reason: 'matches the running core and the published bytes' };
645
+ }
646
+
647
+ // Where a stale pin goes instead. NEVER to the package-core.js rungs: on a file:-pinned
648
+ // consumer hub those abort on an empty core set and leave the new repo empty (task 1002275).
649
+ // stale-bytes → the registry's own bytes for that version (canonical by definition);
650
+ // stale-version → the core the hub has INSTALLED, when it is coreV; else the registry's coreV;
651
+ // neither → refuse, loudly: no project is born on a core nobody can name.
652
+ function recoverFromStalePin({ instanceRoot, exec, log, tag, coreV, check }) {
653
+ const fromRegistry = (raw) => {
654
+ const v = shellSafeVersion(raw); // interpolated into a shell command below
655
+ if (!v) return null;
656
+ const packDest = path.join(os.tmpdir(), `bongos-core-registry-${v}`);
657
+ exec(`mkdir -p ${packDest}`);
658
+ const res = exec(`npm pack ${CORE_PKG}@${v} --pack-destination ${packDest}`, { allowFail: true });
659
+ if (res && res.ok === false) { exec(`rm -rf ${packDest}`); return null; }
660
+ log(` ${tag} packed the published ${v} from the registry instead (task 1002450)`);
661
+ return { tgz: path.join(packDest, `bongos-core-${v}.tgz`), packDest };
662
+ };
663
+ if (check.verdict === 'stale-version') {
664
+ const installed = installedCorePackDir({ instanceRoot, includeFilePins: true });
665
+ const v = installed && shellSafeVersion(installed.version); // interpolated into a shell command below
666
+ if (v && v === coreV) {
667
+ const packDest = path.join(os.tmpdir(), `bongos-core-pack-${v}`);
668
+ log(` ${tag} packing the installed core this control plane runs (v${v}) instead (task 1002450)`);
669
+ exec(`mkdir -p ${packDest}`);
670
+ exec(`npm pack ${installed.dir} --pack-destination ${packDest}`);
671
+ return { tgz: path.join(packDest, installed.tarballName), packDest };
672
+ }
673
+ }
674
+ const v = check.verdict === 'stale-bytes' ? check.pinnedV : coreV;
675
+ const got = fromRegistry(v);
676
+ if (got) return got;
677
+ throw new Error(`refusing to give a new project a stale core: the pinned tarball ${check.reason}, and neither an installed core ${coreV} nor a published ${v} could be packed. Re-run \`bongos upgrade\` on this control plane so its vendor/ tarball matches the core it runs, then retry (task 1002450).`);
678
+ }
679
+
578
680
  // Which core a hosted project is given (task 1004299, ADR 0361 D5). PURE. Since merges
579
681
  // publish as candidates, the platform hall runs ahead of the registry's `latest` label by
580
682
  // design — and the ladder below vends whatever the hall runs, so every new project would
@@ -617,7 +719,8 @@ function projectCoreTarget({ instanceRoot, coreV, exec, log, tag = '[core]' }) {
617
719
  // project gets the released version packed from the registry, not this hall's core.
618
720
  // 1. a `file:`-pinned control plane reuses its own pinned tarball (task 1002275 —
619
721
  // rebuilding via package-core.js aborts with an EMPTY core set when this runner
620
- // IS a consumer instance, leaving new repos empty);
722
+ // IS a consumer instance, leaving new repos empty) — unless checkPinnedTarball
723
+ // finds it STALE, when recoverFromStalePin replaces it (task 1002450);
621
724
  // 2. a REGISTRY-pinned consumer (the post-1002621 mothership shape) packs the core
622
725
  // it has INSTALLED — the child pins the SAME, CURRENT core the hub runs, ending
623
726
  // the frozen-old-tarball births (task 1002706, gap 13) without requiring the
@@ -641,8 +744,14 @@ function resolveVendorableCoreTarball({ instanceRoot, repoRoot = REPO_ROOT, exec
641
744
  }
642
745
  const pinned = installedCoreTarball({ instanceRoot });
643
746
  if (pinned) {
644
- log(` ${tag} reusing the pinned core tarball this control plane runs (${path.basename(pinned)}) — no source rebuild (task 1002275)`);
645
- return { tgz: pinned, packDest: null };
747
+ const check = checkPinnedTarball({ pinned, coreV, exec });
748
+ if (check.verdict === 'fresh' || check.verdict === 'unverifiable') {
749
+ if (check.verdict === 'unverifiable') log(` ${tag} WARNING: cannot verify the pinned core tarball ${path.basename(pinned)} — ${check.reason}; reusing it anyway (task 1002450)`);
750
+ log(` ${tag} reusing the pinned core tarball this control plane runs (${path.basename(pinned)}) — no source rebuild (task 1002275)`);
751
+ return { tgz: pinned, packDest: null };
752
+ }
753
+ log(` ${tag} STALE pinned core tarball ${path.basename(pinned)} — ${check.reason}. NOT vendoring it into a new project (task 1002450)`);
754
+ return recoverFromStalePin({ instanceRoot, exec, log, tag, coreV, check });
646
755
  }
647
756
  const packSrc = installedCorePackDir({ instanceRoot });
648
757
  if (packSrc) {
@@ -1294,6 +1403,7 @@ module.exports = {
1294
1403
  ownerLoginOf,
1295
1404
  asPostgres,
1296
1405
  buildCorePinRefreshCommit,
1406
+ checkPinnedTarball,
1297
1407
  coreCheckoutHasGit,
1298
1408
  dbCreateCmd,
1299
1409
  dbName,
@@ -1318,6 +1428,7 @@ module.exports = {
1318
1428
  migrateCmd,
1319
1429
  onboardMode,
1320
1430
  parseTargetRef,
1431
+ pinnedTarballVersion,
1321
1432
  planCorePinRefresh,
1322
1433
  refreshStandaloneCorePin,
1323
1434
  resolveOwnerGithubToken,
@@ -21,7 +21,12 @@ const { spawnSync } = require('node:child_process');
21
21
  const fs = require('node:fs');
22
22
  const path = require('node:path');
23
23
 
24
- const REPO_ROOT = path.resolve(__dirname, '..', '..');
24
+ // Two roots (task 1005100). CORE_ROOT is where the step scripts live; WORK_ROOT is the tree they
25
+ // check. In a project that installs the core they differ: running the generators from the
26
+ // package dir checked the package's files, so gen-api-docs read STALE while the same --check
27
+ // run from the project said up to date. In a core checkout both are this checkout.
28
+ const CORE_ROOT = path.resolve(__dirname, '..', '..');
29
+ const WORK_ROOT = require('./fitness-lib.js').resolveWorkRoot();
25
30
 
26
31
  // Each step: a label, the argv to run (node scripts run with THIS node), the command that heals it,
27
32
  // and how to read a non-zero exit. `optional` steps are skipped when their script is absent. An `advisory`
@@ -45,8 +50,11 @@ const STEPS = [
45
50
  { key: 'tests', label: 'DB-free unit lane (npm test)', argv: ['scripts/bongos/run-unit-tests.js'], fix: 'read the FAIL lines; compare against origin/main before calling any of them environmental', onlyWith: '--tests', slow: true },
46
51
  ];
47
52
 
48
- function runStep(step, { cwd = REPO_ROOT } = {}) {
49
- const script = path.join(cwd, step.argv[0]);
53
+ // A caller that passes only `cwd` (tests) gets scripts resolved there too, as before.
54
+ function runStep(step, { cwd, coreRoot } = {}) {
55
+ coreRoot = coreRoot || cwd || CORE_ROOT;
56
+ cwd = cwd || WORK_ROOT;
57
+ const script = path.join(coreRoot, step.argv[0]);
50
58
  if (!fs.existsSync(script)) return { ...step, status: step.optional ? 'skipped' : 'missing', ms: 0, tail: `${step.argv[0]} not present` };
51
59
  const t0 = Date.now();
52
60
  const r = spawnSync(process.execPath, [script, ...step.argv.slice(1)], { cwd, encoding: 'utf8', timeout: step.slow ? 15 * 60 * 1000 : 5 * 60 * 1000, env: { ...process.env, GDS_QUIET: '1' } });
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+ // src/bongos/device-flow-blocker.js — tell the OWNER, loudly, that terminal sign-in is off
3
+ // (task 1005033).
4
+ //
5
+ // WHY. GitHub creates an App from a manifest with Device Flow OFF and has no API to switch it
6
+ // on — only a checkbox in the App's settings. Until the owner ticks it, every builder's
7
+ // `bongos setup` / `bongos login` is refused, and the only person told was that builder, in a
8
+ // terminal, who cannot fix it. The provisioning checklist shows the step as optional
9
+ // ("later"), which no owner reads as urgent. So the first time this server sees GitHub's
10
+ // device_flow_disabled refusal it files ONE blocker for the owner, through the ideas
11
+ // module's `ideas.fileBlocker` port (core never imports a module). The writer is idempotent
12
+ // on (source, source_ref), so a thousand refused sign-ins still make one blocker.
13
+ //
14
+ // Best effort by construction: no ideas module, a refused write or a DB hiccup changes
15
+ // nothing about the sign-in reply the builder gets. Never throws, never rejects.
16
+
17
+ const seams = require('../module-seams');
18
+
19
+ const SOURCE = 'auth';
20
+ const SOURCE_REF = 'github-device-flow-disabled';
21
+
22
+ function isDeviceFlowDisabledReason(reason) {
23
+ return /device_flow_disabled/i.test(String(reason || ''));
24
+ }
25
+
26
+ function blockerFor(origin) {
27
+ const settings = origin ? `${origin}/builders/settings` : 'Settings → CLI Access';
28
+ return {
29
+ title: 'Terminal sign-in is off: tick "Enable Device Flow" on the GitHub App',
30
+ bodyMd: [
31
+ 'A builder just tried to sign in from a terminal (`bongos setup` or `bongos login`) and GitHub refused:',
32
+ 'this project\'s GitHub App has **Device Flow switched off**. Apps made by the one-click setup start',
33
+ 'that way, and GitHub offers no way for the setup to switch it on.',
34
+ '',
35
+ '**Fix (the project owner, once, about a minute):** GitHub → Settings → Developer settings →',
36
+ 'GitHub Apps → this project\'s app → tick **Enable Device Flow** → Save. Web sign-in is unaffected.',
37
+ '',
38
+ `Until then builders can still sign in through the web: ${settings}, "Re-issue CLI token".`,
39
+ ].join('\n'),
40
+ source: SOURCE,
41
+ sourceRef: SOURCE_REF,
42
+ };
43
+ }
44
+
45
+ async function raiseDeviceFlowOffBlocker({ reason, origin, resolveOptional = seams.resolveOptional } = {}) {
46
+ try {
47
+ if (!isDeviceFlowDisabledReason(reason)) return null;
48
+ const file = resolveOptional('ideas.fileBlocker', null);
49
+ if (typeof file !== 'function') return null;
50
+ return (await file(blockerFor(origin))) || null;
51
+ } catch (_) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ module.exports = { raiseDeviceFlowOffBlocker, isDeviceFlowDisabledReason, SOURCE, SOURCE_REF };
@@ -42,6 +42,7 @@ const { accountExistenceReadRateLimit } = require('../middleware/rate-limit');
42
42
  const { branding } = require('../../branding');
43
43
  const { resolveEnv, envPrefix } = require('../../instance-config');
44
44
  const seams = require('../../module-seams');
45
+ const { raiseDeviceFlowOffBlocker } = require('../device-flow-blocker');
45
46
  // task 1003208: structured logging — was console.*. A core file logs through
46
47
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
47
48
  const log = require('../logger').logger.child({ src: 'auth' });
@@ -623,6 +624,7 @@ module.exports = function buildAuthRouter() {
623
624
  // fresh-instance auth failure could only be read via curl on the box itself
624
625
  // (finding W2, task 1003052). A 4xx passes the proxy with the JSON intact.
625
626
  const reason = String(err && err.message ? err.message : 'internal error').slice(0, 300);
627
+ raiseDeviceFlowOffBlocker({ reason, origin: WEB_REDIRECT_ORIGIN }); // owner blocker, once (task 1005033)
626
628
  res.fail('device_start_failed', { status: 424, message: reason });
627
629
  }
628
630
  });
@@ -1353,6 +1355,9 @@ module.exports = function buildAuthRouter() {
1353
1355
  paste_command: issuer
1354
1356
  ? `npx bongos paste-token ${token} --api-base ${issuer}`
1355
1357
  : `npx bongos paste-token ${token}`,
1358
+ paste_command_windows: issuer
1359
+ ? `npx.cmd bongos paste-token ${token} --api-base ${issuer}`
1360
+ : `npx.cmd bongos paste-token ${token}`, // task 1005033: PowerShell blocks npx.ps1 by default
1356
1361
  });
1357
1362
  } catch (err) {
1358
1363
  log.error('[gds] POST /auth/cli-token/issue', err);
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.69'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -76,7 +76,8 @@ test('the server emits exactly the verb the CLI registers', () => {
76
76
  assert.ok(cmds.length > 0, 'auth.js should emit at least one paste_command template literal');
77
77
  for (const cmd of cmds) {
78
78
  // `npx bongos <verb> …` since task 1004575 — a clone has no bare `bongos` on PATH.
79
- const verb = cmd.replace(/^npx /, '').split(' ')[1];
79
+ // npx.cmd is the Windows PowerShell form (task 1005033) — same verb after it.
80
+ const verb = cmd.replace(/^npx(\.cmd)? /, '').split(' ')[1];
80
81
  assert.ok(cli.VERBS[verb], `server emits "bongos ${verb}" but the CLI has no such verb`);
81
82
  }
82
83
  });
@@ -174,11 +174,13 @@ t('paste_command names a CLI VERB, never a filesystem path', () => {
174
174
  // applying without anyone editing it.
175
175
  const tail = issueBlock.slice(issueBlock.indexOf('paste_command:'));
176
176
  const cmds = [...tail.matchAll(/`([^`]*bongos paste-token[^`]*)`/g)].map((m) => m[1]);
177
- assert.equal(cmds.length, 2, `expected both ternary branches, got ${cmds.length}`);
177
+ // Four since task 1005033: paste_command's two branches, then paste_command_windows'
178
+ // two — the same line with npx.cmd, because npx.ps1 is blocked in a default PowerShell.
179
+ assert.equal(cmds.length, 4, `expected both ternary branches of both fields, got ${cmds.length}`);
178
180
  for (const cmd of cmds) {
179
181
  // Through npx (task 1004575): a builder's clone does not put bare `bongos` on
180
182
  // PATH, so a bare verb was "command not found" when pasted in the project folder.
181
- assert.ok(/^npx bongos paste-token /.test(cmd),
183
+ assert.ok(/^npx(\.cmd)? bongos paste-token /.test(cmd),
182
184
  `paste_command must start with the npx verb form, got: ${cmd}`);
183
185
  // The invariant is "names no FILE ON THE RECIPIENT'S DISK", which the original
184
186
  // spelling approximated as "contains no slash". That was too broad once the command
@@ -87,7 +87,9 @@ test('Settings CLI access says where to paste the npx command', () => {
87
87
  const js = read('modules/hall-ui/public/settings.js');
88
88
  assert.match(html, /into a terminal in your project folder/);
89
89
  assert.doesNotMatch(html, /bypasses Device Flow/);
90
- assert.match(js, /Paste this into a terminal in your project folder:/);
90
+ // Both spellings (task 1005033) name the project folder AND the cd that gets there.
91
+ assert.match(js, /Open a terminal, cd into your project folder first \(the command only runs there\), then paste this:/);
92
+ assert.match(js, /Open PowerShell, cd into your project folder first \(the command only runs there\), then paste this:/);
91
93
  });
92
94
 
93
95
  test('the npx paste command Settings hands out is pre-approved for a Claude session', () => {
@@ -338,8 +338,8 @@ test('the CLI token reaches the page as text, not as markup', () => {
338
338
  // The secret and the clipboard read the SAME resolved value, so the button can
339
339
  // never copy a different string than the one on screen — nor the literal
340
340
  // "undefined", which writeText(body.paste_command) produced on a body without one.
341
- assert.match(fn[0], /const pasteCommand = body\.paste_command \|\| ''/, 'one resolved value');
342
- assert.match(fn[0], /writeText\(pasteCommand\)/, 'the clipboard copies that same value');
341
+ assert.match(fn[0], /const pasteCommand = \(windows && body\.paste_command_windows\) \|\| body\.paste_command \|\| ''/, 'one resolved value');
342
+ assert.match(fn[0], /copy\(pasteCommand, /, 'the clipboard copies that same value');
343
343
  assert.match(fn[0], /result\.textContent = ''/, 're-issuing replaces the previous token rather than stacking a second');
344
344
  });
345
345