@bongos/core 1.19.207 → 1.19.209

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.207",
6
- "core_contract": "1.19.207",
7
- "source_commit": "f67179175e23c7d9144c3ede28fd365783eed34f",
5
+ "core_version": "1.19.209",
6
+ "core_contract": "1.19.209",
7
+ "source_commit": "7163dda884c4ff7ab746790988dd3612e1271681",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-17T20:29:20.492Z",
9
+ "built_at": "2026-08-17T21:40:11.933Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 310,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1298,
14
+ "functional_verbatim": 1300,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1629,
20
- "tree_sha256": "fb6abbc6818ae6e399e4154a08816d3aeeaf4d9cd65ab89ff1bb3ce3f33428b5",
19
+ "file_count": 1631,
20
+ "tree_sha256": "f933ba33ff7212d28d0d1b4447b760177e211576e00658e3050a7a72528c3bc5",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -187,7 +187,7 @@
187
187
  {
188
188
  "path": ".claude/skills/new-project/SKILL.md",
189
189
  "mode": "0000644",
190
- "sha256": "9b36d441850c335fa634d892088e4c15a2a87acaa9e52808b74a8b2e6e9af39f"
190
+ "sha256": "1d6f60e8fa3cbf06cc5f8901ccf21fd8cac8050f81b3296039b659d700eba242"
191
191
  },
192
192
  {
193
193
  "path": ".claude/skills/otb-character-review/SKILL.md",
@@ -3962,7 +3962,7 @@
3962
3962
  {
3963
3963
  "path": "modules/provisioning/onboard-plan.js",
3964
3964
  "mode": "0000644",
3965
- "sha256": "de7927d2a3c1aac0d40ec5b1ac6c2e98e87cb593c4d3d2bd6ddfe3696371bff6"
3965
+ "sha256": "4aae27c3c56de480e9cc4f19405572a96c6bf0246fdd859adceae629c7a71842"
3966
3966
  },
3967
3967
  {
3968
3968
  "path": "modules/provisioning/pollers/liveness-sweep.js",
@@ -4282,12 +4282,12 @@
4282
4282
  {
4283
4283
  "path": "package-lock.json",
4284
4284
  "mode": "0000644",
4285
- "sha256": "fc64c06226801735546c101af64db40d30492a4ce855303e538bac1111a18930"
4285
+ "sha256": "9a096a5f5cadd3f8dab6e2c958402e36c857ff560a14364c756161c41d573ed3"
4286
4286
  },
4287
4287
  {
4288
4288
  "path": "package.json",
4289
4289
  "mode": "0000644",
4290
- "sha256": "d3457496b5c76bab949eb1693518d88745db75eb7bd3afb4299d68e545cdb0bd"
4290
+ "sha256": "9b699cde73691611630459b846d40b8a887cb51099b8d4193d775fd76f1b9749"
4291
4291
  },
4292
4292
  {
4293
4293
  "path": "public-docs/index.html",
@@ -4547,7 +4547,7 @@
4547
4547
  {
4548
4548
  "path": "public-landing/projects.html",
4549
4549
  "mode": "0000644",
4550
- "sha256": "494d1aa1a6ff006a8e8f239c29d90c052f735919937c9dc6fb6b8eeff4902e91"
4550
+ "sha256": "20dd4fcb471c8d5a4dced2788ca6fffecaba241207bc0087d918d15b60bcc1a1"
4551
4551
  },
4552
4552
  {
4553
4553
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5339,6 +5339,11 @@
5339
5339
  "mode": "0000644",
5340
5340
  "sha256": "c5393ea0633f1fd60b1f6a1e86f7f86be489b74c56d2a0e947f8b262eaeab2cb"
5341
5341
  },
5342
+ {
5343
+ "path": "scripts/gds/seed-bongos-v1-idea-routing-tasks.js",
5344
+ "mode": "0000644",
5345
+ "sha256": "1756751e8c32d9667c82173ac7ba78f98b8416aa946c747036502c5853cbf77f"
5346
+ },
5342
5347
  {
5343
5348
  "path": "scripts/gds/seed-bongos-v1-seo-tasks.js",
5344
5349
  "mode": "0000644",
@@ -5447,7 +5452,7 @@
5447
5452
  {
5448
5453
  "path": "scripts/gds/setup.js",
5449
5454
  "mode": "0000644",
5450
- "sha256": "8e512a07ed885666722a216951dfad7f57fbfd92923bdf5079ce1c6f7b5c0abc"
5455
+ "sha256": "98671f5666ae6bdafa7691bd519367345772c3041680b59c24ec83c56b99f419"
5451
5456
  },
5452
5457
  {
5453
5458
  "path": "scripts/gds/shell.js",
@@ -5897,7 +5902,7 @@
5897
5902
  {
5898
5903
  "path": "src/module-api.js",
5899
5904
  "mode": "0000644",
5900
- "sha256": "167ead50611054c2a0d46ebb14510c993e7c05e9c86428cad2d15d78aae0bad4"
5905
+ "sha256": "efbf7edd3c80dbce81da9aa7473ae1903674c9018da9803e6ab0e807bcb616f0"
5901
5906
  },
5902
5907
  {
5903
5908
  "path": "src/module-loader/catalog.js",
@@ -7382,7 +7387,7 @@
7382
7387
  {
7383
7388
  "path": "tests/onboard.mjs",
7384
7389
  "mode": "0000644",
7385
- "sha256": "c6823a6f0f81bcb4f01ccbd2364d6dcf76eb21e3ff10972738f792438939dee0"
7390
+ "sha256": "71eeb42b47ad2ba11b8379cce263ceac2da42b00b9fe55aab64546027b193364"
7386
7391
  },
7387
7392
  {
7388
7393
  "path": "tests/onboarding_gate.mjs",
@@ -7829,6 +7834,11 @@
7829
7834
  "mode": "0000644",
7830
7835
  "sha256": "a630ac72981f230e8aa6609152765c13e8928ac329cb0ff3fe615371f31208d2"
7831
7836
  },
7837
+ {
7838
+ "path": "tests/setup_device_flow_fallback.mjs",
7839
+ "mode": "0000644",
7840
+ "sha256": "9a9df92b732b4f13d222329877ab17f8f972ef01370c9a918b00635716c76b5e"
7841
+ },
7832
7842
  {
7833
7843
  "path": "tests/setup_smoke.mjs",
7834
7844
  "mode": "0000644",
@@ -9,7 +9,7 @@ You are guiding a new owner from **zero to a live, owned Cloud Bongos instance**
9
9
 
10
10
  A new instance is **standalone** (ADR 0108): it runs from its **own git repo** with `@cloudbongos/core` installed as a **pinned npm dependency**, not a fork of this monorepo. Central updates are a `bongos upgrade` version-pin bump, not a merge. Every step below assumes this shape (`hosting_shape=standalone`).
11
11
 
12
- Five steps are **irreducibly human** and must be rendered as **precise, UI-first, copy-paste-safe prompts** (a non-technical owner does them with browser clicks, no terminal guessing): (1) create the GitHub repo, (2) approve the pre-filled **GitHub App** for sign-in (ONE click — the App Manifest flow, task 2080; no secret to paste), (3) install that App on the repo (ONE click — the publish credential, task 1003049), (4) own a domain, (5) the first sign-in. Everything else is automated. Never ask the owner to do in a terminal what they can do with a click.
12
+ Six steps are **irreducibly human** and must be rendered as **precise, UI-first, copy-paste-safe prompts** (a non-technical owner does them with browser clicks, no terminal guessing): (1) create the GitHub repo, (2) approve the pre-filled **GitHub App** for sign-in (ONE click — the App Manifest flow, task 2080; no secret to paste), (3) tick **Enable Device Flow** in that App's settings (ONE checkbox — terminal sign-in, task 1003051; until then `bongos setup` falls back to a web token), (4) install that App on the repo (ONE click — the publish credential, task 1003049), (5) own a domain, (6) the first sign-in. Everything else is automated. Never ask the owner to do in a terminal what they can do with a click.
13
13
 
14
14
  ## The canonical sequence
15
15
 
@@ -44,6 +44,7 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
44
44
  { key: 'seed-version', kind: 'auto', label: 'Seed the first version (V1) so the instance can take tasks immediately (task 2119)' },
45
45
  { key: 'oauth-app', kind: 'human', label: 'Wire GitHub sign-in — one-click GitHub App (task 2080), or a manual OAuth app' },
46
46
  { key: 'oauth-place', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'Place the OAuth secret — auto via the Manifest flow, or verified manual (task 1979)' },
47
+ { key: 'device-flow', kind: 'human', label: 'Enable Device Flow in the GitHub App settings — one checkbox, so terminal sign-in (bongos login) works (task 1003051)' },
47
48
  { key: 'app-install', kind: 'human', label: 'Install the GitHub App on the repo — the publish credential that lets shipped work land itself (task 1003049, ADR 0055)' },
48
49
  { key: 'first-sign-in', kind: 'human', label: 'First sign-in (seats the founding Archon)' },
49
50
  { key: 'verify', kind: 'auto', label: 'Verify /healthz auth_configured' },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.207",
3
+ "version": "1.19.209",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.207",
9
+ "version": "1.19.209",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.207",
3
+ "version": "1.19.209",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -2861,6 +2861,22 @@ h1[tabindex="-1"]:focus{outline:none;}
2861
2861
  '<button type="button" class="ghbtn" data-action="manifest-start">' + GH_ICON + 'Set up GitHub sign-in</button>' + manualOauthDetails() + '</li>';
2862
2862
  }
2863
2863
 
2864
+ /* device-flow — the one-checkbox repair for terminal sign-in (task 1003051):
2865
+ GitHub creates a manifest-born App with Device Flow OFF and offers no API to
2866
+ enable it, so `bongos setup` in a terminal is refused until the owner ticks
2867
+ the box. Web sign-in works either way; the CLI also has a web-token fallback. */
2868
+ function renderStepDeviceFlow() {
2869
+ if (live.manifest !== 'done') {
2870
+ return '<li class="slocked" data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>Unlocks once GitHub sign-in above is wired. It lets builders sign in from a terminal (<code style="font-family:var(--font-mono)">bongos setup</code>); web sign-in works without it.</p></li>';
2871
+ }
2872
+ var appUrl = live.appSlug ? 'https://github.com/settings/apps/' + esc(live.appSlug) : null;
2873
+ return '<li data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>' +
2874
+ (appUrl
2875
+ ? 'Open <a href="' + appUrl + '" target="_blank" rel="noopener">your app’s settings</a>, check <strong>Enable Device Flow</strong>, and save. '
2876
+ : 'GitHub → Settings → Developer settings → GitHub Apps → your app → check <strong>Enable Device Flow</strong> → save. ') +
2877
+ 'GitHub leaves it off on new apps and only offers it as this checkbox — until it’s ticked, terminal sign-in falls back to a web token from the hall’s Settings page.</p></li>';
2878
+ }
2879
+
2864
2880
  /* app-install — the ADR 0055 publish credential (task 1003049): the sign-in App
2865
2881
  doubles as it, but only an INSTALLED App can mint push tokens; without this
2866
2882
  one click every finished task waits at confirmed for a manual merge. */
@@ -2894,6 +2910,7 @@ h1[tabindex="-1"]:focus{outline:none;}
2894
2910
  var STEP_RENDERERS = {
2895
2911
  'github-repo': renderStepGithubRepo,
2896
2912
  'oauth-app': renderStepOauthApp,
2913
+ 'device-flow': renderStepDeviceFlow,
2897
2914
  'app-install': renderStepAppInstall,
2898
2915
  'first-sign-in': renderStepFirstSignIn,
2899
2916
  'enter-hall': renderStepEnterHall
@@ -0,0 +1,121 @@
1
+ // Seed the tasks for goal 1000071 "Idea routing — work that has a home lands in it" (BONGOS-V1).
2
+ // Source of truth: docs/specs/bongos-v1-idea-routing.md (status: final).
3
+ // Idempotent on (source, source_ref). Re-running resolves to the existing rows.
4
+ //
5
+ // Run: node scripts/gds/seed-bongos-v1-idea-routing-tasks.js
6
+ const os = require('os');
7
+ const sess = require(os.homedir() + '/.config/cloudbongos/gds-session.json');
8
+ const BASE = sess.api_base.replace(/\/$/, '');
9
+ const TOKEN = sess.token;
10
+ const VER = 'BONGOS-V1';
11
+ const GOAL = 1000071;
12
+ const SRC = 'planning-session';
13
+ const META_TASK = 1003059; // this planning session's meta task — moved into the goal
14
+ const DISCORD_BUGS_IDEA = 1000771; // subsumed by R11; marked promoted against it
15
+ const H = { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' };
16
+
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ async function api(method, path, body, _tries = 0) {
19
+ await sleep(400); // base throttle to stay under the per-IP rate limit
20
+ const res = await fetch(`${BASE}/api/gds${path}`, {
21
+ method, headers: H, body: body ? JSON.stringify(body) : undefined,
22
+ });
23
+ const text = await res.text();
24
+ let json; try { json = JSON.parse(text); } catch { json = { _raw: text.slice(0, 300) }; }
25
+ if (res.status === 429 && _tries < 8) {
26
+ const wait = ((json.error && json.error.details && json.error.details.retry_after_seconds) || 30) + 1;
27
+ console.log(` …rate-limited, waiting ${wait}s`);
28
+ await sleep(wait * 1000);
29
+ return api(method, path, body, _tries + 1);
30
+ }
31
+ return { status: res.status, ok: res.ok, json };
32
+ }
33
+
34
+ // Landing matrix (interview-decided): bugs hard-land (Metic+ filer → ready; sub-Metic → backlog);
35
+ // every other goal-bound kind soft-lands (backlog, one Metic+ promote away); homeless → inbox.
36
+ //
37
+ // crit uses the goal's criterion SLUGS via CRIT — positional "Cn" tokens resolve against the
38
+ // whole VERSION's criterion order (and even matched an old criterion literally slugged "C1"),
39
+ // which mislinked the first seed run; slugs are position-independent.
40
+ const CRIT = {
41
+ C1: 'bug-lands-in-its-goal', C2: 'inbox-holds-only-homeless', C3: 'no-route-loses-the-author',
42
+ C4: 'filing-cannot-outrun-rank', C5: 'system-offers-the-home', C6: 'triage-shrinks-to-judgment',
43
+ };
44
+ const TASKS = [
45
+ // T1 — zero-dep layer — P5
46
+ { ref:'R02', kind:'infra', disc:'engineer', prio:5, est:90, crit:['C1','C3'], deps:[], title:'R02 · Schema: idea_inbox.suggested_goal_id + lineage origin \'route\'', desc:'Core migration: nullable suggested_goal_id FK on idea_inbox (routing hint for soft-landed/Discord/homeless ideas). Module migration (ideas_*): extend ideas_task_lineage.origin CHECK with \'route\'. See docs/specs/bongos-v1-idea-routing.md.' },
47
+ { ref:'R03', kind:'bug', disc:'engineer', prio:5, est:90, crit:['C1'], deps:[], title:'R03 · Catch-all fallback never resolves to a non-building version', desc:'generalGoalIdForVersion (modules/lifecycle/db.js:1343) + the null-goal fallback (:1414): a task create must never land in a shipped/frozen version\'s catch-all goal (the 1000771 failure shape). Routed creates with no goal go to the inbox instead. Done-when: test proves a create against a shipped version cannot resolve a catch-all.' },
48
+ { ref:'R07', kind:'feature', disc:'engineer', prio:5, est:120, crit:['C5'], deps:[], title:'R07 · Capture-time goal suggester (advisory consulted BEFORE creation)', desc:'Expose candidate goals for a draft capture — reuse buildGoalAdvisory\'s candidate logic (modules/lifecycle/goal-advisory.js:89) as a pre-creation read (e.g. GET /inbox/goal-suggestions?title=&kind=&version=). Feeds the hall picker (R08), CLI (R09), and Discord suggestion (R11). Done-when: candidates returned for a draft title/kind without creating anything.' },
49
+ { ref:'R10', kind:'feature', disc:'ui', prio:5, est:120, crit:['C5'], deps:[], title:'R10 · Hall move-a-task-to-goal affordance', desc:'PATCH /tasks/:id {goal_id} (task 1763) exists but has no hall UI. Add move-to-goal on the task overlay (Metic+, task.edit) so a wrong routing pick is one reassignment, never a re-file. Done-when: a Metic+ moves a task between same-version goals from the hall.' },
50
+ { ref:'R14', kind:'decision', disc:'engineer', prio:5, est:60, crit:['C2'], deps:[], title:'R14 · ADR: idea routing — capture-time promotion, the landing matrix, the homeless-only inbox', desc:'Write the ADR recording: routing mechanism is capture-time promotion (never inbox bypass — the Discord #bugs lesson), the landing matrix (bugs hard-land by rank; other kinds soft-land; Full Ideas/criterion-proposals/decisions never auto-route), and the inbox\'s new identity (holds only work without a home). Source: docs/specs/bongos-v1-idea-routing.md interview decisions.' },
51
+
52
+ // T2 — the core mechanism — P5
53
+ { ref:'R01', kind:'feature', disc:'engineer', prio:5, est:240, crit:['C1','C3'], deps:['R02'], title:'R01 · Capture-time routing core: POST /inbox accepts goal_id and converts in one transaction', desc:'Extend inbox.captureIdea/route: when goal_id present and the landing matrix says route, one transaction writes the idea row (status=promoted, promoted_to_task_id), the task (in the goal, matrix-decided status), and ideas_task_lineage (origin=route). Validations: goal open + belongs to a building version; Full-grade and criterion-proposal/decision kinds never auto-route; goal-less capture behaves exactly as today. Deps R02.' },
54
+
55
+ // T3 — enforcement + surfaces — P4
56
+ { ref:'R05', kind:'feature', disc:'engineer', prio:4, est:150, crit:['C4'], deps:['R01'], title:'R05 · Rank-aware landing: matrix enforcement server-side', desc:'Metic+ filer\'s bug → status=ready; sub-Metic filer / any non-bug kind → backlog (awaiting any Metic+ promote via the existing task.promote, surfaced in the Needs-your-attention slice). Auto-derived requires_rank floors (ADR 0084) and the goal scope wall run on routed tasks exactly as on authored ones. Deps R01.' },
57
+ { ref:'R11', kind:'feature', disc:'engineer', prio:4, est:180, crit:['C1','C3'], deps:['R01'], title:'R11 · Discord #bugs retired onto the routing path', desc:'Replace discord-bugs.js\'s direct createTask bypass (hardcoded GDS-V3 → goal 1000002 catch-all; destroys attribution) with capture-time routing: idea row + classifier-suggested goal (suggested_goal_id), soft-landing into the pending queue for a one-click Metic+ route. Unlinked authors still yield an attributable idea row (captured_by null). Closes inbox idea 1000771. Deps R01.' },
58
+ { ref:'R12', kind:'feature', disc:'engineer', prio:4, est:120, crit:['C5'], deps:['R02'], title:'R12 · Discord #ideas carries routing hints', desc:'The #ideas inbound (discord-inbound.js) files with no suggested_version at all today. Add: default suggested_version (most-recent building), classifier-suggested suggested_goal_id. Deps R02.' },
59
+ { ref:'R08', kind:'feature', disc:'ui', prio:4, est:240, crit:['C1','C5'], deps:['R01','R07'], title:'R08 · Hall filing surface: file a bug/idea with a goal picker', desc:'The hall has NO capture surface today (triage only). Add file-a-bug/idea: title, body, kind, goal picker fed by the suggester (R07); submits to POST /inbox with goal_id so goal-bound work routes at filing time. Grade capture untouched. Deps R01,R07.' },
60
+ { ref:'R09', kind:'feature', disc:'engineer', prio:4, est:120, crit:['C1','C5'], deps:['R01','R07'], title:'R09 · CLI capture routing: --goal, suggester prompt, active-claim default', desc:'capture.js gains --goal; when the builder holds a claim the suggested goal defaults to the claim\'s goal (interview decision 4); suggester candidates offered otherwise. Deps R01,R07.' },
61
+ { ref:'R13', kind:'feature', disc:'engineer', prio:4, est:120, crit:['C2','C6'], deps:['R07'], title:'R13 · Promote path passes goal_id; /idea-triage goes homeless-only', desc:'task-detail.js (:320) and triage.js (:160) finally send goal_id (suggester-fed default) so even triaged promotions land in a real goal; the /idea-triage skill doc is updated to the homeless-only contract. Deps R07.' },
62
+
63
+ // T4 — proofs — P3
64
+ { ref:'R04', kind:'verify', disc:'engineer', prio:3, est:90, crit:['C3'], deps:['R01'], title:'R04 · PROOF: attribution survives the route end-to-end', desc:'A routed bug\'s task ships; sourceIdeaForTask returns the filer; ideas_task_lineage (origin=route) + captured_by intact — the trail goal 1000067\'s ideator credit will pay against (test). Deps R01.' },
65
+ { ref:'R06', kind:'verify', disc:'engineer', prio:3, est:120, crit:['C4'], deps:['R05'], title:'R06 · PROOF: abuse matrix — filing cannot escalate', desc:'A Xenos mass-filing bugs never lands claimable work, never reaches protected scope, rate limits hold; a sub-Metic cannot reach ready via any capture vector (test suite). Deps R05.' },
66
+
67
+ // T5 — the measured close — P2
68
+ { ref:'R15', kind:'verify', disc:'engineer', prio:2, est:120, crit:['C2','C6'], deps:['R01','R05','R13'], title:'R15 · PROOF: reclassification sweep of the open inbox', desc:'Walk the open idea_inbox (~50 rows at planning time; 14 bugs) under the new rules: every goal-bound bug homed in a real goal; residual inbox is homeless-only; before/after counts recorded in this task\'s notes as the goal\'s measured evidence. Deps R01,R05,R13.' },
69
+ ];
70
+
71
+ const ROOTS = ['R02', 'R03', 'R07', 'R10', 'R14']; // zero-dep, non-spike → open for claiming
72
+
73
+ (async () => {
74
+ const idByRef = {};
75
+ let created = 0, existing = 0;
76
+ console.log(`Seeding ${TASKS.length} tasks under goal ${GOAL} (${VER})…`);
77
+ for (const t of TASKS) {
78
+ const r = await api('POST', '/tasks', {
79
+ version_id: VER, goal_id: GOAL, title: t.title, description: t.desc,
80
+ kind: t.kind, discipline: t.disc, priority: t.prio, est_minutes: t.est,
81
+ criterion_ids: t.crit.map((c) => CRIT[c]), source: SRC, source_ref: `bongos-v1-idea-routing-${t.ref}`,
82
+ });
83
+ if (!r.ok) { console.log(` ${t.ref}: FAILED ${r.status} ${JSON.stringify(r.json)}`); continue; }
84
+ const task = r.json.task || r.json;
85
+ idByRef[t.ref] = task.id;
86
+ if (r.json.idempotent || r.json.existing) existing++; else created++;
87
+ console.log(` ${t.ref} → task ${task.id} (${t.kind}/${t.disc}, P${t.prio}, [${t.crit.join(',')}])`);
88
+ }
89
+
90
+ console.log('Wiring dependencies…');
91
+ let edges = 0;
92
+ for (const t of TASKS) {
93
+ if (!t.deps.length) continue;
94
+ const id = idByRef[t.ref];
95
+ const depIds = t.deps.map((d) => idByRef[d]).filter(Boolean);
96
+ if (!id || depIds.length !== t.deps.length) { console.log(` ${t.ref}: dep resolve gap, skipping`); continue; }
97
+ const r = await api('POST', `/tasks/${id}/dependencies`, { depends_on_task_ids: depIds });
98
+ if (!r.ok) { console.log(` ${t.ref}: dep FAILED ${r.status} ${JSON.stringify(r.json)}`); continue; }
99
+ edges += (r.json.added || depIds.length);
100
+ console.log(` ${t.ref} depends on ${t.deps.join(',')}`);
101
+ }
102
+
103
+ console.log('Promoting the zero-dep layer to ready…');
104
+ for (const ref of ROOTS) {
105
+ const id = idByRef[ref];
106
+ if (!id) continue;
107
+ const r = await api('POST', `/tasks/${id}/promote`, {});
108
+ console.log(` ${ref} (task ${id}): ${r.ok ? 'ready' : 'promote note ' + JSON.stringify(r.json).slice(0, 120)}`);
109
+ }
110
+
111
+ console.log('Linking provenance…');
112
+ if (idByRef.R11) {
113
+ const r = await api('PATCH', `/inbox/${DISCORD_BUGS_IDEA}`, { status: 'promoted', promoted_to_task_id: Number(idByRef.R11) });
114
+ console.log(` idea ${DISCORD_BUGS_IDEA} → ${r.ok ? `promoted against R11 (task ${idByRef.R11})` : 'PATCH note ' + JSON.stringify(r.json).slice(0, 160)}`);
115
+ }
116
+ const meta = await api('PATCH', `/tasks/${META_TASK}`, { goal_id: GOAL });
117
+ console.log(` meta task ${META_TASK} → ${meta.ok ? `goal ${GOAL}` : 'PATCH note ' + JSON.stringify(meta.json).slice(0, 160)}`);
118
+
119
+ console.log(`\nDONE. created=${created} existing=${existing} depEdges=${edges}. Roots opened: ${ROOTS.join(', ')}.`);
120
+ console.log('ref→id:', JSON.stringify(idByRef));
121
+ })();
@@ -273,7 +273,129 @@ async function promptOnboardingConsent() {
273
273
  }
274
274
  }
275
275
 
276
- (async () => {
276
+ // ── W1: the manifest-born App has Device Flow OFF (task 1003051) ─────────────
277
+ // GitHub creates an App from a manifest with Device Flow unchecked and offers
278
+ // no API to enable it (only a settings checkbox), so on a fresh wizard-born
279
+ // instance the documented CLI sign-in is hard-broken until the owner ticks the
280
+ // box. Detect the proxied refusal (POST /auth/device/start surfaces GitHub's
281
+ // device_flow_disabled — task 2126's envelope) and fall back to the hall's
282
+ // CLI-token path so setup still completes. Pure; exported for tests.
283
+ function isDeviceFlowDisabled(start) {
284
+ if (!start || start.ok) return false;
285
+ const err = start.data && start.data.error;
286
+ const text = [
287
+ typeof err === 'string' ? err : '',
288
+ err && typeof err === 'object' ? `${err.code || ''} ${err.message || ''}` : '',
289
+ start.data && start.data.message ? String(start.data.message) : '',
290
+ ].join(' ');
291
+ return /device_flow_disabled/i.test(text);
292
+ }
293
+
294
+ // The plain-language fallback guidance (task 1003051): what happened, the web
295
+ // path that works right now, and the one-checkbox owner fix. Pure; exported.
296
+ function deviceFlowFallbackLines(base) {
297
+ const settings = `${base}/builders/settings`;
298
+ return [
299
+ '',
300
+ "This project's GitHub App has Device Flow switched off, so terminal sign-in",
301
+ 'cannot start. Apps created by the one-click setup begin this way — GitHub',
302
+ 'only offers the switch as a checkbox in the App settings; no setup flow can',
303
+ 'tick it automatically.',
304
+ '',
305
+ 'Sign in through the web instead (works right now):',
306
+ ` 1. Open ${settings} and sign in with GitHub.`,
307
+ ' 2. Under "CLI Access", click "Re-issue CLI token".',
308
+ ' 3. Copy the token it shows and paste it below (or run the printed',
309
+ ' one-liner in another shell, then re-run setup).',
310
+ '',
311
+ 'To repair terminal sign-in for everyone (the project owner, once):',
312
+ ' GitHub → Settings → Developer settings → GitHub Apps → your app →',
313
+ ' check "Enable Device Flow" → Save. Web sign-in is unaffected either way.',
314
+ ];
315
+ }
316
+
317
+ // Verify a hall-minted CLI token against /me and save the session (the
318
+ // paste-token.js contract: nothing is written unless the token verifies).
319
+ // Returns { ok:true, builder } or { ok:false, status }. Exported for tests.
320
+ async function redeemCliToken(token) {
321
+ let res;
322
+ try {
323
+ res = await fetch(`${API_BASE}/api/gds/me`, { headers: { Authorization: `Bearer ${token}` } });
324
+ } catch (err) {
325
+ console.error(`Could not reach ${API_BASE}: ${err.message}`);
326
+ return { ok: false, status: 0 };
327
+ }
328
+ if (!res.ok) {
329
+ console.error(res.status === 401
330
+ ? `Token rejected by ${API_BASE} (HTTP 401) — copy the full token from the Settings page and try again.`
331
+ : `Verification call failed (HTTP ${res.status}).`);
332
+ return { ok: false, status: res.status };
333
+ }
334
+ const me = await res.json();
335
+ await saveSession({ token, builder: me.builder, api_base: API_BASE });
336
+ return { ok: true, builder: me.builder };
337
+ }
338
+
339
+ // Everything setup does AFTER a session is saved — shared verbatim by the
340
+ // Device Flow success path and the W1 CLI-token fallback, so the fallback
341
+ // completes the SAME onboarding end to end (task 1003051).
342
+ async function finishSetup(builder) {
343
+ console.log('');
344
+ console.log(`Authenticated as ${builder.github_login} (#${builder.id})`);
345
+ if (builder.display_name != null) console.log(`Display name: ${builder.display_name}`);
346
+ if (builder.total_credits != null) console.log(`Total credits: ${builder.total_credits}`);
347
+ console.log(`Session saved to ${SESSION_PATH}`);
348
+ // SEC #863: activate the project git hooks (core.hooksPath → .husky) so
349
+ // the tracked hooks run — here that's the pre-push permission gate
350
+ // (task 1002608); an instance may track more. Best effort — an activation
351
+ // hiccup must never fail an otherwise-good setup; the post-push main
352
+ // audit is the fail-closed net regardless.
353
+ try {
354
+ const { activate } = require('./install-git-hooks');
355
+ const r = activate();
356
+ const live = (r.hooks && r.hooks.length) ? r.hooks.join(', ') : 'none tracked';
357
+ if (r.status === 'activated') console.log(`Git hooks activated (live hooks: ${live})`);
358
+ else if (r.status === 'already-active') console.log(`Git hooks already active (live hooks: ${live})`);
359
+ else console.log(`Note: ${r.message}`);
360
+ } catch (e) {
361
+ console.log(`Note: could not activate git hooks (${e.message}); run: git config core.hooksPath .husky`);
362
+ }
363
+ await promptGeminiKey();
364
+ // #1281: deliver the GDS shared art key to entitled newcomers right now, so
365
+ // a brand-new Xenos's first art task works without waiting for the next
366
+ // session start. Best-effort, bounded — never block setup. (Phase 2 will
367
+ // make the promptGeminiKey copy above rank-aware — "we cover your first art
368
+ // tasks; add your own key when you reach Thetes".)
369
+ try {
370
+ const { spawnSync } = require('node:child_process');
371
+ const path = require('node:path');
372
+ spawnSync(process.execPath, [path.join(__dirname, 'fetch-art-key.js')],
373
+ { stdio: 'ignore', timeout: 8000 });
374
+ } catch (_) { /* non-fatal — the session-start sync will deliver it later */ }
375
+ await promptAnthropicLink();
376
+ await promptDisciplines();
377
+ await promptOnboardingConsent();
378
+ // #766: explicit "what's next?" footer. The Xenos path is action-only —
379
+ // no primer to read first — so a newcomer goes straight from setup to
380
+ // surveying the works. Ship three and the city makes you a Thetes.
381
+ try {
382
+ const meRes = await (await cliClient()).me.getMe();
383
+ if (meRes.ok) {
384
+ console.log('');
385
+ printOnboardingNudge(meRes.data);
386
+ console.log('');
387
+ console.log('Newcomer path:');
388
+ console.log(` 1. Choose thy craft: open ${hallBase()} (creative / engineering)`);
389
+ console.log(' 2. Survey work: /builder-start');
390
+ console.log(' 3. Claim + ship: /builder-claim N → /builder-ship');
391
+ console.log(' 4. Ship three works and you rise to Thetes — the whole queue opens.');
392
+ }
393
+ } catch (_) {
394
+ // never let a UX footer break a successful setup
395
+ }
396
+ }
397
+
398
+ async function main() {
277
399
  const forceFlag = process.argv.includes('--force');
278
400
 
279
401
  // Environment preflight (task 1002401, onboarding-audit area 4). Before auth,
@@ -310,6 +432,7 @@ async function promptOnboardingConsent() {
310
432
  dryRunStep(`No existing session at ${SESSION_PATH}; would proceed straight to Device Flow.`);
311
433
  }
312
434
  dryRunStep('Would POST /api/gds/auth/device/start to get a user_code + verification_uri from GitHub.');
435
+ dryRunStep('If GitHub refuses with device_flow_disabled (a one-click App whose checkbox is unticked, task 1003051): would print the web sign-in guidance (hall Settings → CLI Access) and prompt for a pasted CLI token instead of dead-ending.');
313
436
  dryRunStep('Would print the verification_uri and user_code, then poll /api/gds/auth/device/poll every few seconds until the user approves or the code expires.');
314
437
  dryRunStep(`On approval, would write the bearer token + builder profile to ${SESSION_PATH} (chmod 600).`);
315
438
  if (SKIP_KEYS) {
@@ -448,6 +571,31 @@ async function promptOnboardingConsent() {
448
571
  console.log('Starting GitHub Device Flow...');
449
572
  const start = await (await cliClient()).auth.postAuthDeviceStart();
450
573
  if (!start.ok) {
574
+ // W1 (task 1003051): a manifest-born App refuses Device Flow until its owner
575
+ // ticks the checkbox — fall back to the hall's CLI-token path instead of
576
+ // dead-ending the documented setup command.
577
+ if (isDeviceFlowDisabled(start)) {
578
+ for (const line of deviceFlowFallbackLines(API_BASE)) console.log(line);
579
+ console.log('');
580
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
581
+ // Resolve '' on stdin EOF too, so a non-interactive run exits with the
582
+ // guidance instead of hanging on a question nobody can answer.
583
+ const token = (await new Promise((resolve) => {
584
+ rl.question('Paste the CLI token here (or press Enter to quit): ', resolve);
585
+ rl.once('close', () => resolve(''));
586
+ })).trim();
587
+ rl.close();
588
+ if (!token) {
589
+ console.error('No token pasted — run the Settings page one-liner, then re-run setup.');
590
+ process.exit(1);
591
+ }
592
+ const redeemed = await redeemCliToken(token);
593
+ if (!redeemed.ok) process.exit(1);
594
+ console.log('');
595
+ console.log('Signed in via the web token — terminal Device Flow stays broken until the owner ticks the checkbox.');
596
+ await finishSetup(redeemed.builder);
597
+ process.exit(0);
598
+ }
451
599
  console.error(`device/start failed (${start.status}):`, start.data);
452
600
  if (start.data?.error === 'auth_not_configured') {
453
601
  console.error('');
@@ -509,65 +657,19 @@ async function promptOnboardingConsent() {
509
657
  const { token, builder } = poll.data;
510
658
  await saveSession({ token, builder, api_base: API_BASE });
511
659
  console.log('');
512
- console.log('');
513
- console.log(`Authenticated as ${builder.github_login} (#${builder.id})`);
514
- console.log(`Display name: ${builder.display_name}`);
515
- console.log(`Total credits: ${builder.total_credits}`);
516
- console.log(`Session saved to ${SESSION_PATH}`);
517
- // SEC #863: activate the project git hooks (core.hooksPath → .husky) so
518
- // the tracked hooks run — here that's the pre-push permission gate
519
- // (task 1002608); an instance may track more. Best effort — an activation
520
- // hiccup must never fail an otherwise-good setup; the post-push main
521
- // audit is the fail-closed net regardless.
522
- try {
523
- const { activate } = require('./install-git-hooks');
524
- const r = activate();
525
- const live = (r.hooks && r.hooks.length) ? r.hooks.join(', ') : 'none tracked';
526
- if (r.status === 'activated') console.log(`Git hooks activated (live hooks: ${live})`);
527
- else if (r.status === 'already-active') console.log(`Git hooks already active (live hooks: ${live})`);
528
- else console.log(`Note: ${r.message}`);
529
- } catch (e) {
530
- console.log(`Note: could not activate git hooks (${e.message}); run: git config core.hooksPath .husky`);
531
- }
532
- await promptGeminiKey();
533
- // #1281: deliver the GDS shared art key to entitled newcomers right now, so
534
- // a brand-new Xenos's first art task works without waiting for the next
535
- // session start. Best-effort, bounded — never block setup. (Phase 2 will
536
- // make the promptGeminiKey copy above rank-aware — "we cover your first art
537
- // tasks; add your own key when you reach Thetes".)
538
- try {
539
- const { spawnSync } = require('node:child_process');
540
- const path = require('node:path');
541
- spawnSync(process.execPath, [path.join(__dirname, 'fetch-art-key.js')],
542
- { stdio: 'ignore', timeout: 8000 });
543
- } catch (_) { /* non-fatal — the session-start sync will deliver it later */ }
544
- await promptAnthropicLink();
545
- await promptDisciplines();
546
- await promptOnboardingConsent();
547
- // #766: explicit "what's next?" footer. The Xenos path is action-only —
548
- // no primer to read first — so a newcomer goes straight from setup to
549
- // surveying the works. Ship three and the city makes you a Thetes.
550
- try {
551
- const meRes = await (await cliClient()).me.getMe();
552
- if (meRes.ok) {
553
- console.log('');
554
- printOnboardingNudge(meRes.data);
555
- console.log('');
556
- console.log('Newcomer path:');
557
- console.log(` 1. Choose thy craft: open ${hallBase()} (creative / engineering)`);
558
- console.log(' 2. Survey work: /builder-start');
559
- console.log(' 3. Claim + ship: /builder-claim N → /builder-ship');
560
- console.log(' 4. Ship three works and you rise to Thetes — the whole queue opens.');
561
- }
562
- } catch (_) {
563
- // never let a UX footer break a successful setup
564
- }
660
+ await finishSetup(builder);
565
661
  process.exit(0);
566
662
  }
567
663
  }
568
664
  console.error('\nTimed out waiting for approval.');
569
665
  process.exit(1);
570
- })().catch((err) => {
571
- console.error('fatal:', err);
572
- process.exit(1);
573
- });
666
+ }
667
+
668
+ if (require.main === module) {
669
+ main().catch((err) => {
670
+ console.error('fatal:', err);
671
+ process.exit(1);
672
+ });
673
+ }
674
+
675
+ module.exports = { isDeviceFlowDisabled, deviceFlowFallbackLines, redeemCliToken };
package/src/module-api.js CHANGED
@@ -841,8 +841,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
841
841
  // landed since 1.19.205 with no explicit bump. run 32064728346. (task 1002620)
842
842
  // 1.19.207 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
843
843
  // landed since 1.19.206 with no explicit bump. run 32065995444. (task 1002620)
844
+ // 1.19.208 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
845
+ // landed since 1.19.207 with no explicit bump. run 32068332007. (task 1002620)
846
+ // 1.19.209 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
847
+ // landed since 1.19.208 with no explicit bump. run 32072227661. (task 1002620)
844
848
  // ---------------------------------------------------------------------------
845
- const CORE_VERSION = '1.19.207'; // CI auto-patch carrier (ADR 0161); see changelog above
849
+ const CORE_VERSION = '1.19.209'; // CI auto-patch carrier (ADR 0161); see changelog above
846
850
 
847
851
  // A namespaced logger so a module's log lines are attributable + consistent.
848
852
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/onboard.mjs CHANGED
@@ -15,7 +15,7 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
15
15
  assert.equal(keys[keys.length - 2], 'brand');
16
16
  // the irreducibly-human steps (must be UI-first prompts)
17
17
  const human = plan.filter((s) => s.kind === 'human').map((s) => s.key);
18
- assert.deepEqual(human, ['github-repo', 'oauth-app', 'app-install', 'first-sign-in', 'enter-hall']);
18
+ assert.deepEqual(human, ['github-repo', 'oauth-app', 'device-flow', 'app-install', 'first-sign-in', 'enter-hall']);
19
19
  // the DNS preflight comes BEFORE provision (task 1980: before the address step)
20
20
  assert.ok(keys.indexOf('dns-preflight') < keys.indexOf('provision'), 'dns preflight precedes provision');
21
21
  // OAuth secret placement comes after the OAuth app + before first sign-in
@@ -25,6 +25,10 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
25
25
  // must be prompted before the owner walks away into the hall.
26
26
  assert.ok(keys.indexOf('oauth-place') < keys.indexOf('app-install'), 'the App exists before it can be installed');
27
27
  assert.ok(keys.indexOf('app-install') < keys.indexOf('first-sign-in'), 'install prompted before sign-in');
28
+ // The Device Flow checkbox (task 1003051 / gap W1) also needs the App to exist,
29
+ // and belongs with the sign-in wiring it repairs.
30
+ assert.ok(keys.indexOf('oauth-place') < keys.indexOf('device-flow'), 'the App exists before its checkbox can be ticked');
31
+ assert.ok(keys.indexOf('device-flow') < keys.indexOf('first-sign-in'), 'checkbox prompted before sign-in');
28
32
  });
29
33
 
30
34
  test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
@@ -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);