@bongos/core 1.24.69 → 1.24.70
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 +42 -27
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -2
- package/docs/copy-inventory.md +36 -32
- package/docs/copy-registry.json +86 -50
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-readings.json +7 -7
- package/modules/hall-ui/public/settings.js +49 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +9 -1
- package/scripts/bongos/docs-entropy.js +4 -1
- package/scripts/bongos/fitness-lib.js +19 -1
- package/scripts/bongos/paste-token.js +75 -4
- package/scripts/bongos/ship-check.js +11 -3
- package/src/bongos/device-flow-blocker.js +56 -0
- package/src/bongos/routes/auth.js +5 -0
- package/src/module-api.js +1 -1
- package/tests/bongos_cli.mjs +2 -1
- package/tests/cli_token_reissue.mjs +4 -2
- package/tests/hall_onboarding_bongos_setup.mjs +3 -1
- package/tests/hall_settings_world.mjs +2 -2
- package/tests/paste_token_windows.mjs +165 -0
- package/tests/vendored_work_root.mjs +72 -0
|
@@ -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
|
-
|
|
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
|
|
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 };
|
|
@@ -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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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.
|
|
84
|
+
const CORE_VERSION = '1.24.70'; // 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');
|
package/tests/bongos_cli.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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], /
|
|
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
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// tests/paste_token_windows.mjs — a first-time CLI sign-in on Windows took four failed
|
|
2
|
+
// attempts (task 1005033). Three of the four failure points are in code:
|
|
3
|
+
//
|
|
4
|
+
// 2. The hall's one-liner starts with `npx`. In Windows PowerShell `npx` resolves to
|
|
5
|
+
// npx.ps1, which the DEFAULT execution policy refuses to run ("running scripts is
|
|
6
|
+
// disabled on this system"). `npx.cmd` is the same program without the script
|
|
7
|
+
// host, and works in PowerShell and cmd alike. The hall now hands a Windows
|
|
8
|
+
// browser the npx.cmd form.
|
|
9
|
+
// 3. The one-liner only resolves `bongos` inside the project folder (npx finds the
|
|
10
|
+
// project's own bin); from anywhere else npm says "could not determine executable
|
|
11
|
+
// to run". Nothing said so — the hall now says it in the instruction itself.
|
|
12
|
+
// 4. The token sat on the command line (shell history). paste-token now also reads it
|
|
13
|
+
// from a hidden prompt, or from stdin when piped, when no token argument is given.
|
|
14
|
+
//
|
|
15
|
+
// 1. The App's Device Flow switch was off. GitHub has no API to tick it, so the fix is
|
|
16
|
+
// telling the OWNER: the first refusal files one idempotent blocker
|
|
17
|
+
// (src/bongos/device-flow-blocker.js). Task 1005118 later replaces the paste route
|
|
18
|
+
// with a browser approval.
|
|
19
|
+
//
|
|
20
|
+
// Run: node --test tests/paste_token_windows.mjs
|
|
21
|
+
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import assert from 'node:assert/strict';
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { PassThrough } from 'node:stream';
|
|
27
|
+
import { createRequire } from 'node:module';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const { readTokenFromInput } = require(path.join(ROOT, 'scripts', 'bongos', 'paste-token.js'));
|
|
33
|
+
|
|
34
|
+
const sink = () => { const s = new PassThrough(); s.text = ''; s.on('data', (c) => { s.text += c; }); return s; };
|
|
35
|
+
|
|
36
|
+
// ── 4. the token can arrive without touching the command line ────────────────
|
|
37
|
+
|
|
38
|
+
test('a piped token is read from stdin and trimmed', async () => {
|
|
39
|
+
const input = new PassThrough();
|
|
40
|
+
const p = readTokenFromInput({ input, output: sink() });
|
|
41
|
+
input.end(' abcdef0123456789abcdef0123456789 \r\n');
|
|
42
|
+
assert.equal(await p, 'abcdef0123456789abcdef0123456789');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('an interactive prompt reads one line and never echoes the token', async () => {
|
|
46
|
+
const input = new PassThrough();
|
|
47
|
+
input.isTTY = true;
|
|
48
|
+
const modes = [];
|
|
49
|
+
input.setRawMode = (on) => { modes.push(on); input.isRaw = on; };
|
|
50
|
+
const output = sink();
|
|
51
|
+
const p = readTokenFromInput({ input, output });
|
|
52
|
+
input.write('abcdef01234567');
|
|
53
|
+
input.write('X\u007f'); // a typo, then backspace
|
|
54
|
+
input.write('89abcdef\r');
|
|
55
|
+
const token = await p;
|
|
56
|
+
assert.equal(token, 'abcdef0123456789abcdef');
|
|
57
|
+
assert.ok(!output.text.includes('abcdef'), `the token must not be echoed, got: ${JSON.stringify(output.text)}`);
|
|
58
|
+
assert.match(output.text, /token/i, 'it asks for the token');
|
|
59
|
+
assert.deepEqual(modes, [true, false], 'raw mode is switched on for the read and back off after');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('Ctrl+C at the prompt cancels instead of saving a half-typed token', async () => {
|
|
63
|
+
const input = new PassThrough();
|
|
64
|
+
input.isTTY = true;
|
|
65
|
+
input.setRawMode = () => {};
|
|
66
|
+
const p = readTokenFromInput({ input, output: sink() });
|
|
67
|
+
input.write('abc\u0003');
|
|
68
|
+
await assert.rejects(p, /cancel/i);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('paste-token falls back to the prompt when no token argument is given', () => {
|
|
72
|
+
const src = fs.readFileSync(path.join(ROOT, 'scripts', 'bongos', 'paste-token.js'), 'utf8');
|
|
73
|
+
const main = src.slice(src.indexOf('async function main()'));
|
|
74
|
+
assert.match(main, /readTokenFromInput\(/, 'main reads the token from input when argv has none');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ── 2 + 3. the hall's one-liner works in a default Windows PowerShell ─────────
|
|
78
|
+
|
|
79
|
+
const routeSrc = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'routes', 'auth.js'), 'utf8');
|
|
80
|
+
const issueBlock = (() => {
|
|
81
|
+
const start = routeSrc.indexOf("'/auth/cli-token/issue'");
|
|
82
|
+
const next = routeSrc.indexOf('\n router.', start);
|
|
83
|
+
return routeSrc.slice(start, next === -1 ? routeSrc.length : next);
|
|
84
|
+
})();
|
|
85
|
+
|
|
86
|
+
test('the issue route also composes a Windows one-liner that avoids npx.ps1', () => {
|
|
87
|
+
const m = issueBlock.match(/paste_command_windows:\s*issuer\s*\?\s*`([^`]+)`\s*:\s*`([^`]+)`/);
|
|
88
|
+
assert.ok(m, 'paste_command_windows should be a readable template literal pair');
|
|
89
|
+
for (const cmd of [m[1], m[2]]) {
|
|
90
|
+
assert.match(cmd, /^npx\.cmd bongos paste-token \$\{token\}/, `npx.cmd, not npx — got: ${cmd}`);
|
|
91
|
+
}
|
|
92
|
+
assert.match(m[1], /--api-base \$\{issuer\}/, 'the Windows form carries the issuer too');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const settingsJs = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'settings.js'), 'utf8');
|
|
96
|
+
const issueFn = (settingsJs.match(/async function issueCliToken\([\s\S]*?\n \}\n/) || [''])[0];
|
|
97
|
+
|
|
98
|
+
test('the hall shows the Windows one-liner to a Windows browser', () => {
|
|
99
|
+
assert.ok(issueFn, 'issueCliToken is a whole function');
|
|
100
|
+
assert.match(issueFn, /paste_command_windows/, 'it reads the Windows form');
|
|
101
|
+
assert.match(settingsJs, /function isWindowsBrowser\(/, 'it decides by the browser platform');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('the hall says the command only runs from inside the project folder', () => {
|
|
105
|
+
assert.match(issueFn, /cd/, 'the instruction names cd');
|
|
106
|
+
assert.match(issueFn, /only (runs|works) (from )?(there|inside)/i, 'and says why');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── grade round 2 (task 1005033) ─────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
test('a pipe that never closes still settles on the first line', async () => {
|
|
112
|
+
const input = new PassThrough();
|
|
113
|
+
const p = readTokenFromInput({ input, output: sink() });
|
|
114
|
+
input.write('abcdef0123456789abcdef\nsomething else that never ends');
|
|
115
|
+
assert.equal(await p, 'abcdef0123456789abcdef');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('Ctrl+D on an empty prompt cancels instead of saving nothing', async () => {
|
|
119
|
+
const input = new PassThrough();
|
|
120
|
+
input.isTTY = true;
|
|
121
|
+
input.setRawMode = () => {};
|
|
122
|
+
const p = readTokenFromInput({ input, output: sink() });
|
|
123
|
+
input.write('\u0004');
|
|
124
|
+
await assert.rejects(p, /cancel/i);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('the hall offers the token-free command with the token on its own button', () => {
|
|
128
|
+
assert.match(issueFn, /bongos paste-token\$\{body\.api_base/, 'a command with no token in it');
|
|
129
|
+
assert.match(issueFn, /Copy token/, 'the token is copied separately');
|
|
130
|
+
assert.match(issueFn, /Keep the token out of your shell history/, 'and it says why');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ── 1. the owner hears about Device Flow being off ────────────────────────────
|
|
134
|
+
|
|
135
|
+
const { raiseDeviceFlowOffBlocker, SOURCE, SOURCE_REF } = require(path.join(ROOT, 'src', 'bongos', 'device-flow-blocker.js'));
|
|
136
|
+
|
|
137
|
+
test('a device_flow_disabled refusal files ONE owner blocker through ideas.fileBlocker', async () => {
|
|
138
|
+
const filed = [];
|
|
139
|
+
const resolveOptional = (port) => (port === 'ideas.fileBlocker' ? async (b) => { filed.push(b); return { id: 1 }; } : null);
|
|
140
|
+
const out = await raiseDeviceFlowOffBlocker({ reason: 'device_flow_disabled', origin: 'https://x.example', resolveOptional });
|
|
141
|
+
assert.deepEqual(out, { id: 1 });
|
|
142
|
+
assert.equal(filed.length, 1);
|
|
143
|
+
assert.equal(filed[0].source, SOURCE);
|
|
144
|
+
assert.equal(filed[0].sourceRef, SOURCE_REF, 'a fixed source_ref makes the writer idempotent');
|
|
145
|
+
assert.match(filed[0].title, /Enable Device Flow/);
|
|
146
|
+
assert.match(filed[0].bodyMd, /https:\/\/x\.example\/builders\/settings/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('any other start failure files nothing', async () => {
|
|
150
|
+
let called = false;
|
|
151
|
+
const resolveOptional = () => async () => { called = true; };
|
|
152
|
+
assert.equal(await raiseDeviceFlowOffBlocker({ reason: 'bad_client_id', resolveOptional }), null);
|
|
153
|
+
assert.equal(called, false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('no ideas module, or a failing write, never breaks the sign-in reply', async () => {
|
|
157
|
+
assert.equal(await raiseDeviceFlowOffBlocker({ reason: 'device_flow_disabled', resolveOptional: () => null }), null);
|
|
158
|
+
const boom = () => async () => { throw new Error('db down'); };
|
|
159
|
+
assert.equal(await raiseDeviceFlowOffBlocker({ reason: 'device_flow_disabled', resolveOptional: boom }), null);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('the device/start route raises it on the refusal', () => {
|
|
163
|
+
const start = routeSrc.slice(routeSrc.indexOf("'/auth/device/start'"), routeSrc.indexOf("'/auth/device/start'") + 2500);
|
|
164
|
+
assert.match(start, /raiseDeviceFlowOffBlocker\(\{ reason/);
|
|
165
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// tests/vendored_work_root.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1005100 - feedback instance vendors: in a project that installs the core from npm
|
|
4
|
+
// (toast, core 1.21.115), docs-entropy.js and ship-check.js took their root from
|
|
5
|
+
// path.resolve(__dirname, '..', '..') — the PACKAGE dir under node_modules/@bongos/core.
|
|
6
|
+
// docs-entropy then ran `git ls-files '*.md'` there, enumerated 0 files and died with a
|
|
7
|
+
// "scan defect"; ship-check ran every generator from the package dir, so gen-api-docs read
|
|
8
|
+
// STALE while `npx bongos exec scripts/bongos/gen-api-docs.js --check` from the project
|
|
9
|
+
// said up to date. resolveWorkRoot() is the root these tools work on: the core checkout when
|
|
10
|
+
// the core is a checkout, the project (pointer, else the git top of cwd, else cwd) when it
|
|
11
|
+
// is installed. ship-check spawns the script from the core and runs it in that root.
|
|
12
|
+
//
|
|
13
|
+
// Run: node tests/vendored_work_root.mjs
|
|
14
|
+
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { createRequire } from 'node:module';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const require = createRequire(import.meta.url);
|
|
25
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const { resolveWorkRoot, CORE_ROOT } = require('../scripts/bongos/fitness-lib.js');
|
|
27
|
+
const { runStep } = require('../scripts/bongos/ship-check.js');
|
|
28
|
+
|
|
29
|
+
const real = (p) => fs.realpathSync.native(p);
|
|
30
|
+
const tmp = (name) => fs.mkdtempSync(path.join(os.tmpdir(), `vwr-${name}-`));
|
|
31
|
+
|
|
32
|
+
test('a core checkout works on the core root, whatever the cwd', () => {
|
|
33
|
+
assert.equal(resolveWorkRoot({ installed: false, cwd: os.tmpdir() }), CORE_ROOT);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('an installed core with a pointer works on the pointed-at project', () => {
|
|
37
|
+
assert.equal(resolveWorkRoot({ installed: true, cwd: os.tmpdir(), explicit: () => '/proj' }), '/proj');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('an installed core with no pointer works on the git top of cwd, not the package dir', () => {
|
|
41
|
+
const repo = tmp('repo');
|
|
42
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
43
|
+
const sub = path.join(repo, 'docs');
|
|
44
|
+
fs.mkdirSync(sub);
|
|
45
|
+
const got = resolveWorkRoot({ installed: true, cwd: sub, explicit: () => null });
|
|
46
|
+
assert.equal(real(got), real(repo));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an installed core outside any repo falls back to cwd', () => {
|
|
50
|
+
const dir = tmp('norepo');
|
|
51
|
+
const got = resolveWorkRoot({ installed: true, cwd: dir, explicit: () => null, gitTop: () => null });
|
|
52
|
+
assert.equal(got, dir);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('ship-check runs a step from the core but inside the work root', () => {
|
|
56
|
+
const core = tmp('core');
|
|
57
|
+
const inst = tmp('inst');
|
|
58
|
+
fs.mkdirSync(path.join(core, 'scripts', 'bongos'), { recursive: true });
|
|
59
|
+
fs.writeFileSync(path.join(core, 'scripts', 'bongos', 'where.js'), 'console.log("cwd=" + process.cwd());\n');
|
|
60
|
+
const r = runStep({ key: 'w', label: 'w', argv: ['scripts/bongos/where.js'], fix: 'f' }, { coreRoot: core, cwd: inst });
|
|
61
|
+
assert.equal(r.status, 'ok', r.tail);
|
|
62
|
+
assert.match(r.tail, /cwd=/);
|
|
63
|
+
assert.equal(real(r.tail.trim().replace(/^cwd=/, '')), real(inst));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('docs-entropy and ship-check no longer hardwire their root to the package dir', () => {
|
|
67
|
+
for (const f of ['scripts/bongos/docs-entropy.js', 'scripts/bongos/ship-check.js']) {
|
|
68
|
+
const src = fs.readFileSync(path.join(ROOT, f), 'utf8');
|
|
69
|
+
assert.doesNotMatch(src, /const REPO_ROOT = path\.resolve\(__dirname, '\.\.', '\.\.'\)/, f);
|
|
70
|
+
assert.match(src, /resolveWorkRoot\(\)/, f);
|
|
71
|
+
}
|
|
72
|
+
});
|