@bongos/core 1.19.206 → 1.19.208
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 +41 -26
- package/.claude/skills/new-project/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +156 -3
- package/docs/api-reference.md +4 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/provisioning/migrations/provisioning_009_project_settings.sql +19 -0
- package/modules/provisioning/onboard-plan.js +1 -0
- package/modules/provisioning/provisioning.js +41 -2
- package/modules/provisioning/routes/provisioning.js +63 -0
- package/modules/provisioning/tests/provisioning.mjs +38 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/projects.html +17 -0
- package/scripts/gds/setup.js +160 -58
- package/src/module-api.js +5 -1
- package/tests/onboard.mjs +5 -1
- package/tests/provisioning_settings.mjs +200 -0
- package/tests/setup_device_flow_fallback.mjs +179 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// tests/setup_device_flow_fallback.mjs
|
|
2
|
+
//
|
|
3
|
+
// W1 (task 1003051): on a wizard-born instance the GitHub App has Device Flow
|
|
4
|
+
// OFF (the manifest can't enable it, GitHub offers only a settings checkbox),
|
|
5
|
+
// so `bongos setup` — the documented CLI sign-in — dead-ended on a proxied
|
|
6
|
+
// GitHub refusal. setup.js now detects that exact refusal and falls back to the
|
|
7
|
+
// hall's CLI-token path.
|
|
8
|
+
//
|
|
9
|
+
// Covers the fallback branch's machinery, each leg against the stubbed refusal
|
|
10
|
+
// or a stubbed instance API:
|
|
11
|
+
// • isDeviceFlowDisabled — the detector against the REAL server envelope
|
|
12
|
+
// (device_start_failed 502 carrying GitHub's device_flow_disabled), a legacy
|
|
13
|
+
// flat shape, and the shapes it must NOT match (auth_not_configured, other
|
|
14
|
+
// upstream failures, success)
|
|
15
|
+
// • deviceFlowFallbackLines — the guidance names the hall Settings page,
|
|
16
|
+
// CLI Access, and the owner's one-checkbox repair
|
|
17
|
+
// • redeemCliToken — driven in a CHILD process (hermetic HOME/USERPROFILE +
|
|
18
|
+
// GDS_API_BASE at a local stub): a good token verifies via /me and writes
|
|
19
|
+
// the session; a bad token is refused and writes NOTHING
|
|
20
|
+
//
|
|
21
|
+
// The interactive TTY walk (prompt → paste) is deliberately not spawned here:
|
|
22
|
+
// the spawn-the-whole-CLI harness (setup_smoke.mjs) is already environment-
|
|
23
|
+
// sensitive, and every seam the prompt wires together is covered above.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/setup_device_flow_fallback.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import http from 'node:http';
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const setup = require('../scripts/gds/setup.js');
|
|
38
|
+
|
|
39
|
+
const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
const SETUP_JS = path.join(REPO_ROOT, 'scripts', 'gds', 'setup.js');
|
|
41
|
+
|
|
42
|
+
let passed = 0, failed = 0;
|
|
43
|
+
const t = (name, fn) => {
|
|
44
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
45
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
46
|
+
};
|
|
47
|
+
const ta = async (name, fn) => {
|
|
48
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
49
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
console.log('isDeviceFlowDisabled — the refusal detector:');
|
|
53
|
+
|
|
54
|
+
// The REAL shape: routes/auth.js res.fail('device_start_failed', {status:502,
|
|
55
|
+
// message: 'GitHub rejected device-flow start: device_flow_disabled — …'}).
|
|
56
|
+
const REAL_REFUSAL = {
|
|
57
|
+
ok: false, status: 502,
|
|
58
|
+
data: { error: { code: 'device_start_failed', message: 'GitHub rejected device-flow start: device_flow_disabled — terminal sign-in needs Device Flow enabled on the app: open https://github.com/settings/apps, open your app, check "Enable Device Flow", and save. Web sign-in is unaffected.' } },
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
t('matches the real device_start_failed envelope carrying device_flow_disabled', () => {
|
|
62
|
+
assert.equal(setup.isDeviceFlowDisabled(REAL_REFUSAL), true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
t('matches a legacy flat error shape too', () => {
|
|
66
|
+
assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 502, data: { error: 'device_start_failed', message: 'x device_flow_disabled y' } }), true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
t('does NOT match auth_not_configured, other upstream failures, or success', () => {
|
|
70
|
+
assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 503, data: { error: { code: 'auth_not_configured', message: 'GitHub OAuth App credentials missing on the server.' } } }), false);
|
|
71
|
+
assert.equal(setup.isDeviceFlowDisabled({ ok: false, status: 502, data: { error: { code: 'device_start_failed', message: 'GitHub rejected device-flow start: incorrect_client_credentials' } } }), false);
|
|
72
|
+
assert.equal(setup.isDeviceFlowDisabled({ ok: true, status: 200, data: { device_code: 'x' } }), false);
|
|
73
|
+
assert.equal(setup.isDeviceFlowDisabled(null), false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
console.log('\ndeviceFlowFallbackLines — the guidance:');
|
|
77
|
+
|
|
78
|
+
t('names the Settings page, CLI Access, and the owner checkbox repair', () => {
|
|
79
|
+
const text = setup.deviceFlowFallbackLines('https://spike.cloudbongos.com').join('\n');
|
|
80
|
+
assert.match(text, /https:\/\/spike\.cloudbongos\.com\/builders\/settings/, 'points at the hall Settings page');
|
|
81
|
+
assert.match(text, /CLI Access/, 'names the section');
|
|
82
|
+
assert.match(text, /Re-issue CLI token/, 'names the button');
|
|
83
|
+
assert.match(text, /Enable Device Flow/, 'names the owner checkbox');
|
|
84
|
+
assert.match(text, /Web sign-in is unaffected/i, 'says what still works');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── redeemCliToken in a hermetic child against a stubbed instance API ────────
|
|
88
|
+
// A child process because API_BASE + the session path resolve at module load:
|
|
89
|
+
// the child gets GDS_API_BASE → the stub and HOME/USERPROFILE → a throwaway
|
|
90
|
+
// dir, so the real session can never be touched. The driver prints the JSON
|
|
91
|
+
// result and lets node exit naturally (no process.exit — the Windows async.c
|
|
92
|
+
// abort gotcha), so assertions read stdout, not the exit code.
|
|
93
|
+
|
|
94
|
+
const GOOD_TOKEN = 'a'.repeat(64);
|
|
95
|
+
const server = http.createServer((req, res) => {
|
|
96
|
+
const send = (status, body) => {
|
|
97
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
98
|
+
res.end(JSON.stringify(body));
|
|
99
|
+
};
|
|
100
|
+
if (req.method === 'GET' && req.url.endsWith('/api/gds/me')) {
|
|
101
|
+
if ((req.headers.authorization || '') === `Bearer ${GOOD_TOKEN}`) {
|
|
102
|
+
return send(200, { builder: { id: 6, github_login: 'newbuilder', display_name: 'New Builder' } });
|
|
103
|
+
}
|
|
104
|
+
return send(401, { error: { code: 'unauthenticated' } });
|
|
105
|
+
}
|
|
106
|
+
return send(404, { error: { code: 'not_found' } });
|
|
107
|
+
});
|
|
108
|
+
server.listen(0, '127.0.0.1');
|
|
109
|
+
await new Promise((r) => server.once('listening', r));
|
|
110
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
111
|
+
|
|
112
|
+
const driverPath = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'w1-driver-')), 'driver.cjs');
|
|
113
|
+
fs.writeFileSync(driverPath, [
|
|
114
|
+
`const s = require(${JSON.stringify(SETUP_JS)});`,
|
|
115
|
+
`s.redeemCliToken(process.argv[2]).then((r) => { console.log('RESULT ' + JSON.stringify(r)); });`,
|
|
116
|
+
].join('\n'));
|
|
117
|
+
|
|
118
|
+
// ASYNC spawn, not spawnSync: the stub server lives in THIS process, and a sync
|
|
119
|
+
// spawn would block the event loop — the child's fetch would deadlock waiting
|
|
120
|
+
// for a server that can never accept.
|
|
121
|
+
function redeemInChild(token, home) {
|
|
122
|
+
return new Promise((resolve) => {
|
|
123
|
+
const child = spawn(process.execPath, [driverPath, token], {
|
|
124
|
+
env: { ...process.env, HOME: home, USERPROFILE: home, GDS_API_BASE: BASE, OTB_API_BASE: BASE },
|
|
125
|
+
});
|
|
126
|
+
let stdout = '', stderr = '';
|
|
127
|
+
child.stdout.on('data', (c) => { stdout += c; });
|
|
128
|
+
child.stderr.on('data', (c) => { stderr += c; });
|
|
129
|
+
const timer = setTimeout(() => child.kill(), 30_000);
|
|
130
|
+
child.on('close', (code, signal) => {
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
const line = stdout.split('\n').find((l) => l.startsWith('RESULT '));
|
|
133
|
+
resolve({ result: line ? JSON.parse(line.slice(7)) : null, raw: { stdout, stderr, code, signal } });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function findSessions(home) {
|
|
139
|
+
const hits = [];
|
|
140
|
+
(function walk(dir) {
|
|
141
|
+
let entries = [];
|
|
142
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
143
|
+
for (const f of entries) {
|
|
144
|
+
const p = path.join(dir, f.name);
|
|
145
|
+
if (f.isDirectory()) walk(p);
|
|
146
|
+
else if (f.name === 'gds-session.json') hits.push(p);
|
|
147
|
+
}
|
|
148
|
+
})(home);
|
|
149
|
+
return hits;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
console.log('\nredeemCliToken — verify-then-save against the stub:');
|
|
153
|
+
|
|
154
|
+
await ta('a good token verifies via /me and writes the session under the hermetic HOME', async () => {
|
|
155
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'w1-home-'));
|
|
156
|
+
const { result, raw } = await redeemInChild(GOOD_TOKEN, home);
|
|
157
|
+
assert.ok(result, `driver printed no RESULT\nstdout:\n${raw.stdout}\nstderr:\n${raw.stderr}`);
|
|
158
|
+
assert.equal(result.ok, true);
|
|
159
|
+
assert.equal(result.builder.github_login, 'newbuilder');
|
|
160
|
+
const sessions = findSessions(home);
|
|
161
|
+
assert.equal(sessions.length, 1, `exactly one session file expected under ${home}`);
|
|
162
|
+
const session = JSON.parse(fs.readFileSync(sessions[0], 'utf8'));
|
|
163
|
+
assert.equal(session.token, GOOD_TOKEN);
|
|
164
|
+
assert.equal(session.api_base, BASE, 'the session points at the instance that minted the token');
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
await ta('a bad token is refused (401) and NOTHING is written', async () => {
|
|
168
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'w1-home-'));
|
|
169
|
+
const { result, raw } = await redeemInChild('b'.repeat(64), home);
|
|
170
|
+
assert.ok(result, `driver printed no RESULT\nstdout:\n${raw.stdout}\nstderr:\n${raw.stderr}`);
|
|
171
|
+
assert.equal(result.ok, false);
|
|
172
|
+
assert.equal(result.status, 401);
|
|
173
|
+
assert.match(raw.stderr, /Token rejected/, 'says the token was refused');
|
|
174
|
+
assert.equal(findSessions(home).length, 0, 'no session written for an unverified token');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.close();
|
|
178
|
+
console.log(`\nsetup_device_flow_fallback: ${passed} passed, ${failed} failed`);
|
|
179
|
+
process.exit(failed ? 1 : 0);
|