@bongos/core 1.19.528 → 1.19.530

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.
@@ -1493,5 +1493,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1493
1493
  landed since 1.19.526 with no explicit bump. run 33827382028. (task 1002620)
1494
1494
  1.19.528 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1495
1495
  landed since 1.19.527 with no explicit bump. run 33829339717. (task 1002620)
1496
+ 1.19.529 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1497
+ landed since 1.19.528 with no explicit bump. run 33877197857. (task 1002620)
1498
+ 1.19.530 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1499
+ landed since 1.19.529 with no explicit bump. run 33878817005. (task 1002620)
1496
1500
  ---------------------------------------------------------------------------
1497
1501
  ```
@@ -39,42 +39,20 @@
39
39
 
40
40
  'use strict';
41
41
 
42
- const { bundleForProjectType, ALWAYS_ON_CORE } = require('./starter-bundles');
42
+ const { bundleForAnswers, TEAM_SHAPE_RULES, ALWAYS_ON_CORE } = require('./starter-bundles');
43
43
  const { PROJECT_DETAIL_VOCAB } = require('./provisioning');
44
44
 
45
- // How the team shape adjusts the type's starter bundle, in application order.
46
- // Each entry is one condition (`teamShape`) and one effect on ONE opt-in module,
47
- // carrying the sentence a picker shows when it fires. Deliberately tiny: ADR 0237
48
- // already made the per-type preset the opinion, and today's opt-in catalog is
49
- // three modules wide (`agents`, `dev-box`, `discord`) — so this table's job is to
50
- // correct a preset the owner's answer contradicts, not to re-derive it.
45
+ // `TEAM_SHAPE_RULES` MOVED to starter-bundles.js (BV1.R22, task 1002341; ADR 0243)
46
+ // and is re-exported here so this file's public surface is unchanged. It went to
47
+ // the bundle layer because a bundle adjustment is not advice about the bundle —
48
+ // it IS the bundle once the owner has answered, and `effectiveModules` is the
49
+ // function ADR 0240 §2 hangs the whole skip contract on. A rule the creation
50
+ // picker applies but that fallback does not is two lists kept in agreement by
51
+ // hand, which is the failure ADR 0240 §2 was written to make impossible.
51
52
  //
52
- // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
53
- // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
54
- // `tests/provisioning_recommendations.mjs` fails the build over it).
55
- const TEAM_SHAPE_RULES = Object.freeze([
56
- Object.freeze({
57
- id: 'a-community-needs-somewhere-to-gather',
58
- teamShape: 'community',
59
- effect: 'add',
60
- module: 'discord',
61
- reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
62
- }),
63
- Object.freeze({
64
- id: 'a-team-shares-one-environment',
65
- teamShape: 'small-team',
66
- effect: 'add',
67
- module: 'dev-box',
68
- reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
69
- }),
70
- Object.freeze({
71
- id: 'building-alone-needs-no-shared-environment',
72
- teamShape: 'solo',
73
- effect: 'remove',
74
- module: 'dev-box',
75
- reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
76
- }),
77
- ]);
53
+ // What stays HERE is the advice: a governance preset that is only ever previewed,
54
+ // and an invite SEEK nothing in this module may run. Neither is a thing a project
55
+ // silently ends up with, so neither belongs in the read-time resolution.
78
56
 
79
57
  // The governance presets, by key. A preset is a NAME plus the one sentence that
80
58
  // says what living under it feels like — never a set of roles or permissions,
@@ -167,25 +145,6 @@ function normalizeTeamShape(value) {
167
145
  return PROJECT_DETAIL_VOCAB.team_shape.includes(value) ? value : null;
168
146
  }
169
147
 
170
- // Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
171
- // list plus the adjustments that ACTUALLY changed it — a rule whose module the
172
- // bundle already carries (or already lacks) records nothing, because a stated
173
- // reason for a change that did not happen is a lie the owner cannot check.
174
- // Never touches the always-on core. PURE.
175
- function adjustBundle(bundle, teamShape) {
176
- const modules = [...bundle];
177
- const adjustments = [];
178
- for (const rule of TEAM_SHAPE_RULES) {
179
- if (rule.teamShape !== teamShape) continue;
180
- const at = modules.indexOf(rule.module);
181
- if (rule.effect === 'add' && at === -1) modules.push(rule.module);
182
- else if (rule.effect === 'remove' && at !== -1) modules.splice(at, 1);
183
- else continue;
184
- adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
185
- }
186
- return { modules, adjustments };
187
- }
188
-
189
148
  // The whole recommendation for one set of creation answers:
190
149
  //
191
150
  // {
@@ -211,8 +170,10 @@ function adjustBundle(bundle, teamShape) {
211
170
  function recommendationsForDetails(answers) {
212
171
  const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
213
172
  const teamShape = normalizeTeamShape(given.team_shape);
214
- const preset = bundleForProjectType(given.type);
215
- const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
173
+ // The SAME resolver the picker preselects from and effectiveModules falls back
174
+ // to (ADR 0243) — so the recommendation, the panel and the project a skipped
175
+ // picker ends up with are one function's answer, not three that agree.
176
+ const preset = bundleForAnswers(given.type, teamShape);
216
177
 
217
178
  const gov = GOVERNANCE_RULES.find((r) => r.teamShape === teamShape) || GOVERNANCE_DEFAULT;
218
179
  const invite = INVITE_RULES.find((r) => r.teamShape === teamShape) || INVITE_DEFAULT;
@@ -226,9 +187,9 @@ function recommendationsForDetails(answers) {
226
187
  label: preset.label,
227
188
  summary: preset.summary,
228
189
  core: [...ALWAYS_ON_CORE],
229
- bundle: modules,
230
- modules: [...ALWAYS_ON_CORE, ...modules],
231
- adjustments,
190
+ bundle: preset.bundle,
191
+ modules: preset.modules,
192
+ adjustments: preset.adjustments,
232
193
  },
233
194
  governance: {
234
195
  preset: gov.preset,
@@ -0,0 +1,159 @@
1
+ // modules/provisioning/routes/callback-page.js — the standalone HTML the public
2
+ // GitHub-App-Manifest redirect lands on (task 1003570; the page itself is task 2096).
3
+ //
4
+ // WHY. This is pure PRESENTATION for exactly one route — `GET /provisioning/
5
+ // github-app/callback` in routes/provisioning.js — and it is ~135 lines of escaper,
6
+ // palette, stylesheet, client script and template with no request, DB or auth logic
7
+ // in it. Carrying it inside the route file pushed that file onto the 1500-line
8
+ // OVERSIZE_LINES fitness ratchet (scripts/gds/fitness-ratchets.js), where the next
9
+ // single added line failed `unit` and the only obvious escape — raising the baseline
10
+ // — is the one thing the ratchet forbids (ADR 0243 §4/§5 for the argument that put
11
+ // it there). Moved here verbatim so the route file has room to change again.
12
+ //
13
+ // Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
14
+ // 0083); it is required by routes/provisioning.js and by nothing else.
15
+ //
16
+ // Shape: no DB, no network, no request object — given a plain options bag it returns
17
+ // a string. `esc` and `callbackPage` are the whole surface; PAGE_STYLE and
18
+ // CALLBACK_SCRIPT stay private because they are only ever this page's own body.
19
+ 'use strict';
20
+
21
+ const api = require('../../../src/module-api');
22
+
23
+ // Minimal HTML escaper for the callback landing page (no template engine here).
24
+ function esc(s) {
25
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
26
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
27
+ ));
28
+ }
29
+
30
+ // Accent pair resolved from the instance's own branding — the pack is the palette
31
+ // authority (task 1002810); the literal fallbacks are the neutral pack's values, so
32
+ // a branding validation error still paints the current family, never a stale one.
33
+ const ACCENT = (() => {
34
+ try {
35
+ const c = api.branding().theme?.ui?.colors || {};
36
+ return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
37
+ } catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
38
+ })();
39
+
40
+ // Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
41
+ // Quicksand (accents from the branding pack, config/branding.neutral.json resolved
42
+ // under the instance's own config/branding.json). Light + dark aware; no external
43
+ // dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
44
+ const PAGE_STYLE = `
45
+ :root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
46
+ @media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
47
+ *{box-sizing:border-box}html,body{height:100%}
48
+ body{margin:0;font-family:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,var(--bg1) 0%,var(--bg2) 45%,var(--bg3) 100%);background-attachment:fixed;display:flex;align-items:center;justify-content:center;padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
49
+ .card{width:100%;max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:2.4rem 2rem 2rem;text-align:center;box-shadow:0 18px 50px -22px rgba(120,60,10,.4),0 2px 8px rgba(120,60,10,.07)}
50
+ .badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
51
+ h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
52
+ .sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
53
+ .sub p{margin:.6rem 0}
54
+ code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rgba(56,189,248,.14);color:var(--sky600);padding:.12em .45em;border-radius:6px;font-size:.9em;overflow-wrap:anywhere}
55
+ @media (prefers-color-scheme:dark){code{color:var(--sky)}}
56
+ .steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
57
+ .step{flex:1 1 0;max-width:9rem;position:relative}
58
+ .step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
59
+ .step:first-child::before{display:none}
60
+ .dot{width:34px;height:34px;border-radius:50%;margin:0 auto;display:grid;place-items:center;background:var(--bg3);border:2px solid var(--line);color:var(--inkSoft);font-weight:700;font-size:.95rem;position:relative;z-index:1;transition:all .4s ease}
61
+ .lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
62
+ .step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
63
+ .step.done::before{background:var(--ok)}
64
+ .step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
65
+ .step.active .lbl{color:var(--ink)}
66
+ .step.active .dot::after{content:"";position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--sun);animation:spin .8s linear infinite}
67
+ @keyframes spin{to{transform:rotate(360deg)}}
68
+ @keyframes pulse{0%,100%{box-shadow:0 0 0 4px rgba(249,115,22,.15)}50%{box-shadow:0 0 0 9px rgba(249,115,22,.04)}}
69
+ @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
70
+ .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
71
+ .cta{margin-top:1.4rem}
72
+ .btn{display:inline-block;background:var(--sun);color:var(--sunInk);text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
73
+ .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
74
+ .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
75
+ .hidden{display:none!important}
76
+ `;
77
+
78
+ // Client script for the PROGRESS variant only (task 2096): poll the public status
79
+ // endpoint, advance the stepper, and auto-open the instance the moment its credentials
80
+ // land. Deliberately contains NO backticks and NO ${...} so it survives the outer
81
+ // template literal verbatim; the only server data it reads is the JSON config block
82
+ // written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
83
+ // manual "open" button rather than ever falsely erroring. The auto-open countdown runs
84
+ // ONLY when this page is the wizard's pop-up; a same-tab return (pop-up blocked) keeps
85
+ // the founder here with the door and a way back to the checklist (task 1003236).
86
+ const CALLBACK_SCRIPT = `
87
+ (function(){
88
+ var cfg=window.__CB_SETUP__||{};
89
+ var state=cfg.state||'',domain=cfg.domain||'';
90
+ if(!state||!domain)return;
91
+ var target='https://'+domain;
92
+ var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
93
+ var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
94
+ var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot'),back=document.getElementById('back');
95
+ /* Opened from the wizard as a pop-up (window.opener alive, or the tab still carries the name the wizard gave it):
96
+ the checklist is in the other tab, so this one may auto-open the instance. Same-tab fallback (pop-up blocked,
97
+ form target=_self): this IS the founder's checklist tab — never navigate it away; offer both doors (task 1003236). */
98
+ var popup=!!window.opener||window.name==='ghAppSetup';
99
+ var finished=false,tries=0,manual=false;
100
+ function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
101
+ function setActive(n){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active','done');if(i<n){el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}else if(i===n){el.classList.add('active');}}}
102
+ function allDone(){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active');el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}}
103
+ function ready(){if(finished)return;finished=true;allDone();statusEl.textContent='Your instance is ready \\u2014 sign in with GitHub to claim it as owner.';cta.classList.remove('hidden');
104
+ if(!popup){go.textContent='Open '+domain+' \\u2192';if(back)back.classList.remove('hidden');if(foot)foot.textContent='Your setup checklist is waiting where you left it \\u2014 finish it first, or open your instance now.';return;}
105
+ var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
106
+ function failed(){if(finished)return;finished=true;statusEl.textContent='Setup didn\\u2019t finish. Open your instance page and start the GitHub sign-in setup again.';cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';}
107
+ function revealManual(){if(manual||finished)return;manual=true;cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';if(foot)foot.textContent='This is taking a little longer than usual \\u2014 you can open your instance now, or keep waiting.';}
108
+ function schedule(){if(!finished)setTimeout(poll,3000);}
109
+ function poll(){tries++;fetch(pollUrl,{headers:{accept:'application/json'},cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(j){if(!j){schedule();return;}if(j.ready||j.status==='done'){ready();return;}if(j.status==='error'||j.status==='expired'){failed();return;}setActive(2);if(tries===40)revealManual();schedule();}).catch(function(){schedule();});}
110
+ setActive(2);poll();
111
+ })();
112
+ `;
113
+
114
+ // The standalone landing page for the public GitHub redirect (the mothership serves it;
115
+ // it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
116
+ // • progress — ok + state + domain: an animated stepper that polls the status endpoint
117
+ // and auto-opens the instance when its credentials are placed.
118
+ // • message — everything else (errors + the no-domain success fallback): a calm badge
119
+ // + `body` (server-authored, trusted HTML).
120
+ function callbackPage({ ok, title, body, state, domain }) {
121
+ const progress = !!(ok && state && domain);
122
+ const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
123
+ const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
124
+ <meta name="viewport" content="width=device-width, initial-scale=1">
125
+ <meta name="color-scheme" content="light dark">
126
+ <title>${esc(title)}</title>
127
+ <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
128
+ <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Quicksand:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
129
+ <style>${PAGE_STYLE}</style></head><body>`;
130
+ if (progress) {
131
+ return `${head}
132
+ <main class="card">
133
+ <h1>${esc(title)}</h1>
134
+ <p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
135
+ <div class="steps" id="steps" aria-label="Setup progress">
136
+ <div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
137
+ <div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
138
+ <div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
139
+ </div>
140
+ <p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
141
+ <div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
142
+ <p class="muted hidden" id="back"><a href="/projects?view=new">Finish your remaining setup steps →</a></p>
143
+ <p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
144
+ <noscript><p class="muted">When setup finishes, open <a href="https://${esc(domain)}">${esc(domain)}</a> and sign in with GitHub — that first sign-in makes you the owner.</p></noscript>
145
+ </main>
146
+ <script>window.__CB_SETUP__=${cfgJson};</script>
147
+ <script>${CALLBACK_SCRIPT}</script>
148
+ </body></html>`;
149
+ }
150
+ return `${head}
151
+ <main class="card">
152
+ <div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
153
+ <h1>${esc(title)}</h1>
154
+ <div class="sub">${body || ''}</div>
155
+ </main>
156
+ </body></html>`;
157
+ }
158
+
159
+ module.exports = { esc, callbackPage };
@@ -27,6 +27,9 @@
27
27
  //
28
28
  // Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
29
29
  // 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
30
+ // The GitHub-App callback page's presentation (esc / ACCENT / the stylesheet / the
31
+ // client script / callbackPage) lives in ./callback-page.js — moved out under task
32
+ // 1003570 because this file had reached the 1500-line fitness ratchet.
30
33
  'use strict';
31
34
 
32
35
  const crypto = require('node:crypto');
@@ -36,9 +39,10 @@ const auth = api;
36
39
  const { pool, validateOrRespond, parseId } = api;
37
40
  const provisioning = require('../provisioning');
38
41
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
39
- const { ALWAYS_ON_CORE, OPTIONAL_MODULES, allStarterBundles, optionalModules } = require('../starter-bundles');
42
+ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
40
43
  const { recommendationsForDetails } = require('../recommendations');
41
44
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
45
+ const { callbackPage } = require('./callback-page');
42
46
  // task 1003208: structured logging (pino via the doorway) — was console.*.
43
47
  const log = api.logger('provisioning');
44
48
 
@@ -139,142 +143,6 @@ function badModuleSelection(modules) {
139
143
  return null;
140
144
  }
141
145
 
142
- // Minimal HTML escaper for the callback landing page (no template engine here).
143
- function esc(s) {
144
- return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
145
- { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
146
- ));
147
- }
148
-
149
- // Accent pair resolved from the instance's own branding — the pack is the palette
150
- // authority (task 1002810); the literal fallbacks are the neutral pack's values, so
151
- // a branding validation error still paints the current family, never a stale one.
152
- const ACCENT = (() => {
153
- try {
154
- const c = api.branding().theme?.ui?.colors || {};
155
- return { sun: c.accent || '#fb9569', sun600: c.accentDeep || '#a3481a' };
156
- } catch { return { sun: '#fb9569', sun600: '#a3481a' }; }
157
- })();
158
-
159
- // Brand styling for the standalone onboarding pages — sunrise palette + Baloo 2 /
160
- // Quicksand (accents from the branding pack, config/branding.neutral.json resolved
161
- // under the instance's own config/branding.json). Light + dark aware; no external
162
- // dependency beyond an optional Google-Fonts link (system fallback if it doesn't load).
163
- const PAGE_STYLE = `
164
- :root{color-scheme:light dark;--sun:${ACCENT.sun};--sun600:${ACCENT.sun600};--sunInk:#2a1305;--coral:#fb7185;--sky:#38bdf8;--sky600:#0284c7;--ok:#16a34a;--warn:#f59e0b;--ink:#2a1f17;--inkSoft:#6b5d52;--card:#fffdfa;--line:#f0e2cf;--bg1:#fde8c8;--bg2:#fff4e6;--bg3:#fffdfa}
165
- @media (prefers-color-scheme:dark){:root{--ink:#f5efe6;--inkSoft:#cbbdad;--card:#241b13;--line:#3a2c1e;--bg1:#2a1d10;--bg2:#1b130b;--bg3:#130d07}}
166
- *{box-sizing:border-box}html,body{height:100%}
167
- body{margin:0;font-family:"Quicksand",system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:linear-gradient(180deg,var(--bg1) 0%,var(--bg2) 45%,var(--bg3) 100%);background-attachment:fixed;display:flex;align-items:center;justify-content:center;padding:1.5rem;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
168
- .card{width:100%;max-width:30rem;background:var(--card);border:1px solid var(--line);border-radius:20px;padding:2.4rem 2rem 2rem;text-align:center;box-shadow:0 18px 50px -22px rgba(120,60,10,.4),0 2px 8px rgba(120,60,10,.07)}
169
- .badge-warn{font-size:2.4rem;line-height:1;margin:0 auto 1rem}
170
- h1{font-family:"Baloo 2",system-ui,sans-serif;font-weight:700;font-size:1.5rem;line-height:1.15;margin:.2rem 0 .55rem}
171
- .sub{color:var(--inkSoft);font-size:1.02rem;line-height:1.5;margin:0 auto;max-width:24rem}
172
- .sub p{margin:.6rem 0}
173
- code{font-family:"Fira Code",ui-monospace,SFMono-Regular,monospace;background:rgba(56,189,248,.14);color:var(--sky600);padding:.12em .45em;border-radius:6px;font-size:.9em;overflow-wrap:anywhere}
174
- @media (prefers-color-scheme:dark){code{color:var(--sky)}}
175
- .steps{display:flex;align-items:flex-start;justify-content:center;margin:1.9rem 0 1.1rem}
176
- .step{flex:1 1 0;max-width:9rem;position:relative}
177
- .step::before{content:"";position:absolute;top:16px;left:-50%;width:100%;height:3px;background:var(--line);z-index:0;transition:background .4s ease}
178
- .step:first-child::before{display:none}
179
- .dot{width:34px;height:34px;border-radius:50%;margin:0 auto;display:grid;place-items:center;background:var(--bg3);border:2px solid var(--line);color:var(--inkSoft);font-weight:700;font-size:.95rem;position:relative;z-index:1;transition:all .4s ease}
180
- .lbl{margin-top:.55rem;font-size:.78rem;color:var(--inkSoft);font-weight:600;line-height:1.25;padding:0 .2rem}
181
- .step.done .dot{background:var(--ok);border-color:var(--ok);color:#fff}
182
- .step.done::before{background:var(--ok)}
183
- .step.active .dot{border-color:var(--sun);color:var(--sun600);animation:pulse 1.5s ease-in-out infinite}
184
- .step.active .lbl{color:var(--ink)}
185
- .step.active .dot::after{content:"";position:absolute;inset:-3px;border-radius:50%;border:2px solid transparent;border-top-color:var(--sun);animation:spin .8s linear infinite}
186
- @keyframes spin{to{transform:rotate(360deg)}}
187
- @keyframes pulse{0%,100%{box-shadow:0 0 0 4px rgba(249,115,22,.15)}50%{box-shadow:0 0 0 9px rgba(249,115,22,.04)}}
188
- @media (prefers-reduced-motion:reduce){.step.active .dot,.step.active .dot::after{animation:none}}
189
- .status-line{min-height:1.4em;color:var(--inkSoft);font-size:.94rem;margin:.4rem auto 0;max-width:24rem;line-height:1.45}
190
- .cta{margin-top:1.4rem}
191
- .btn{display:inline-block;background:var(--sun);color:var(--sunInk);text-decoration:none;font-weight:700;font-family:"Baloo 2",system-ui,sans-serif;padding:.72rem 1.5rem;border-radius:999px;font-size:1.02rem;box-shadow:0 10px 22px -8px rgba(249,115,22,.7);transition:transform .15s ease,box-shadow .15s ease}
192
- .btn:hover{transform:translateY(-1px);box-shadow:0 14px 28px -8px rgba(249,115,22,.8)}
193
- .muted{color:var(--inkSoft);opacity:.9;font-size:.85rem;margin:1.1rem auto 0;max-width:24rem;line-height:1.45}
194
- .hidden{display:none!important}
195
- `;
196
-
197
- // Client script for the PROGRESS variant only (task 2096): poll the public status
198
- // endpoint, advance the stepper, and auto-open the instance the moment its credentials
199
- // land. Deliberately contains NO backticks and NO ${...} so it survives the outer
200
- // template literal verbatim; the only server data it reads is the JSON config block
201
- // written just above it. Fails soft — a poll hiccup just retries; a long wait reveals a
202
- // manual "open" button rather than ever falsely erroring. The auto-open countdown runs
203
- // ONLY when this page is the wizard's pop-up; a same-tab return (pop-up blocked) keeps
204
- // the founder here with the door and a way back to the checklist (task 1003236).
205
- const CALLBACK_SCRIPT = `
206
- (function(){
207
- var cfg=window.__CB_SETUP__||{};
208
- var state=cfg.state||'',domain=cfg.domain||'';
209
- if(!state||!domain)return;
210
- var target='https://'+domain;
211
- var pollUrl=location.pathname.replace('/callback','/status')+'?state='+encodeURIComponent(state);
212
- var steps=document.getElementById('steps'),statusEl=document.getElementById('status');
213
- var cta=document.getElementById('cta'),go=document.getElementById('go'),foot=document.getElementById('foot'),back=document.getElementById('back');
214
- /* Opened from the wizard as a pop-up (window.opener alive, or the tab still carries the name the wizard gave it):
215
- the checklist is in the other tab, so this one may auto-open the instance. Same-tab fallback (pop-up blocked,
216
- form target=_self): this IS the founder's checklist tab — never navigate it away; offer both doors (task 1003236). */
217
- var popup=!!window.opener||window.name==='ghAppSetup';
218
- var finished=false,tries=0,manual=false;
219
- function dot(n){return steps.querySelector('[data-step="'+n+'"]');}
220
- function setActive(n){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active','done');if(i<n){el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}else if(i===n){el.classList.add('active');}}}
221
- function allDone(){for(var i=1;i<=3;i++){var el=dot(i);if(!el)continue;el.classList.remove('active');el.classList.add('done');el.querySelector('.dot').textContent='\\u2713';}}
222
- function ready(){if(finished)return;finished=true;allDone();statusEl.textContent='Your instance is ready \\u2014 sign in with GitHub to claim it as owner.';cta.classList.remove('hidden');
223
- if(!popup){go.textContent='Open '+domain+' \\u2192';if(back)back.classList.remove('hidden');if(foot)foot.textContent='Your setup checklist is waiting where you left it \\u2014 finish it first, or open your instance now.';return;}
224
- var n=4;(function tick(){if(n<=0){location.href=target;return;}go.textContent='Opening '+domain+' in '+n+'\\u2026';n--;setTimeout(tick,1000);})();}
225
- function failed(){if(finished)return;finished=true;statusEl.textContent='Setup didn\\u2019t finish. Open your instance page and start the GitHub sign-in setup again.';cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';}
226
- function revealManual(){if(manual||finished)return;manual=true;cta.classList.remove('hidden');go.textContent='Open '+domain+' \\u2192';if(foot)foot.textContent='This is taking a little longer than usual \\u2014 you can open your instance now, or keep waiting.';}
227
- function schedule(){if(!finished)setTimeout(poll,3000);}
228
- function poll(){tries++;fetch(pollUrl,{headers:{accept:'application/json'},cache:'no-store'}).then(function(r){return r.ok?r.json():null;}).then(function(j){if(!j){schedule();return;}if(j.ready||j.status==='done'){ready();return;}if(j.status==='error'||j.status==='expired'){failed();return;}setActive(2);if(tries===40)revealManual();schedule();}).catch(function(){schedule();});}
229
- setActive(2);poll();
230
- })();
231
- `;
232
-
233
- // The standalone landing page for the public GitHub redirect (the mothership serves it;
234
- // it is NOT the hall shell). Two variants share the Cloud Bongos brand shell (task 2096):
235
- // • progress — ok + state + domain: an animated stepper that polls the status endpoint
236
- // and auto-opens the instance when its credentials are placed.
237
- // • message — everything else (errors + the no-domain success fallback): a calm badge
238
- // + `body` (server-authored, trusted HTML).
239
- function callbackPage({ ok, title, body, state, domain }) {
240
- const progress = !!(ok && state && domain);
241
- const cfgJson = JSON.stringify({ state: state || '', domain: domain || '' }).replace(/</g, '\\u003c');
242
- const head = `<!doctype html><html lang="en"><head><meta charset="utf-8">
243
- <meta name="viewport" content="width=device-width, initial-scale=1">
244
- <meta name="color-scheme" content="light dark">
245
- <title>${esc(title)}</title>
246
- <link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
247
- <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@600;700;800&family=Quicksand:wght@400;500;600;700&family=Fira+Code&display=swap" rel="stylesheet">
248
- <style>${PAGE_STYLE}</style></head><body>`;
249
- if (progress) {
250
- return `${head}
251
- <main class="card">
252
- <h1>${esc(title)}</h1>
253
- <p class="sub">Finishing sign-in setup for <code>${esc(domain)}</code>.</p>
254
- <div class="steps" id="steps" aria-label="Setup progress">
255
- <div class="step done" data-step="1"><div class="dot">✓</div><div class="lbl">GitHub app created</div></div>
256
- <div class="step active" data-step="2"><div class="dot">2</div><div class="lbl">Placing credentials</div></div>
257
- <div class="step" data-step="3"><div class="dot">3</div><div class="lbl">Ready to sign in</div></div>
258
- </div>
259
- <p class="status-line" id="status">Hang tight — keep this tab open while we finish.</p>
260
- <div class="cta hidden" id="cta"><a class="btn" id="go" href="https://${esc(domain)}">Open ${esc(domain)} →</a></div>
261
- <p class="muted hidden" id="back"><a href="/projects?view=new">Finish your remaining setup steps →</a></p>
262
- <p class="muted" id="foot">This page opens <code>${esc(domain)}</code> for you the moment it’s ready.</p>
263
- <noscript><p class="muted">When setup finishes, open <a href="https://${esc(domain)}">${esc(domain)}</a> and sign in with GitHub — that first sign-in makes you the owner.</p></noscript>
264
- </main>
265
- <script>window.__CB_SETUP__=${cfgJson};</script>
266
- <script>${CALLBACK_SCRIPT}</script>
267
- </body></html>`;
268
- }
269
- return `${head}
270
- <main class="card">
271
- <div class="badge-warn">${ok ? '✓' : '⚠️'}</div>
272
- <h1>${esc(title)}</h1>
273
- <div class="sub">${body || ''}</div>
274
- </main>
275
- </body></html>`;
276
- }
277
-
278
146
  // Register this module's kernel seam (ADR 0083 / the registerBoxSeams precedent).
279
147
  // Idempotent — the loader calls this factory once at boot, but tests may
280
148
  // instantiate the router repeatedly; gating on hasProvider keeps the port
@@ -840,6 +708,8 @@ module.exports = function provisioningRoutes() {
840
708
  // project is now on the map, and one who cleared it learns it just left.
841
709
  // Re-deriving it from a follow-up GET would let the two answers disagree.
842
710
  publish: publishVerdict(updated),
711
+ // `team_shape` MOVES a bundle-sourced project's modules — effect with cause (ADR 0243 §5).
712
+ modules: effectiveModules(updated),
843
713
  });
844
714
  } catch (err) {
845
715
  log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
@@ -1337,8 +1207,10 @@ module.exports = function provisioningRoutes() {
1337
1207
  // row for. The alternatives were both worse: `GET /modules` needs a session
1338
1208
  // (this wizard is read signed-out) and `GET /public/modules` serves booleans,
1339
1209
  // not copy. Additive — `core` and `bundles` keep their shape.
1210
+ // `byTeamShape` re-resolves each bundle for every answer the About step can give,
1211
+ // riding THIS payload rather than a second read (task 1002341; ADR 0243 §4).
1340
1212
  router.get('/provisioning/starter-bundles', (req, res) => {
1341
- res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles(), optional: optionalModules() });
1213
+ res.json({ core: [...ALWAYS_ON_CORE], bundles: starterBundleAnswerTable(), optional: optionalModules() });
1342
1214
  });
1343
1215
 
1344
1216
  // GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>