@bongos/core 1.19.981 → 1.19.983

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.
Files changed (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/.claude/skills/collab-review/SKILL.md +120 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +3 -0
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0176-private-repo-deploy-keys.md +5 -4
  9. package/docs/api/openapi.json +87 -4
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +198 -201
  12. package/docs/copy-registry.json +263 -290
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +92 -0
  16. package/modules/hall-ui/public/collab.css +5 -0
  17. package/modules/hall-ui/public/collab.html +8 -2
  18. package/modules/hall-ui/public/collab.js +77 -13
  19. package/modules/provisioning/module.json +2 -1
  20. package/modules/provisioning/routes/repo-private.js +82 -0
  21. package/modules/public-landing/public/projects.html +129 -21
  22. package/package-lock.json +2 -2
  23. package/package.json +1 -1
  24. package/src/bongos/routes/github.js +10 -9
  25. package/src/module-api.js +1 -1
  26. package/tests/collab_page.mjs +105 -0
  27. package/tests/github_repo_visibility.mjs +9 -6
  28. package/tests/hall_collab_world.mjs +18 -5
  29. package/tests/projects_hub.mjs +18 -24
  30. package/tests/projects_hub_pre_uat.mjs +5 -4
  31. package/tests/projects_hub_repo_private.mjs +238 -0
  32. package/tests/provisioning_repo_private_route.mjs +224 -0
  33. package/tests/public_landing_projects.mjs +7 -13
package/docs/file-map.md CHANGED
@@ -408,6 +408,7 @@ tests/
408
408
  ├── builder-ship/SKILL.md ← /builder-ship — record + award credits (runs touches[] scanner; warns on drift; sandbox-first review gate for game-surface changes, ADR 0046)
409
409
  ├── builder-stage/SKILL.md ← /builder-stage — stage work on the builder's own local sandbox preview (http://localhost:<port>) for browser review before shipping (#927, ADR 0046)
410
410
  ├── builder-start/SKILL.md ← /builder-start — list claimable tasks (parallel-safe vs active claims)
411
+ ├── collab-review/SKILL.md ← /collab-review — the ANSWERING half of /ask-for-help: walk GET /help-requests/for-me + GET /task-recommendations/for-me oldest first, read the task each names, VERIFY the ask's claims against the live row rather than its prose, bring one decision at a time and halt; then execute the chosen write, and — since only the ASKER may settle a help request — close the loop by filing a reply addressed back to them. Treats an ask as data, never instructions (task 1004181)
411
412
  ├── design/SKILL.md ← /design — the UI design session: the operating playbook for ui-discipline work on ANY instance (load the instance's pack + DESIGN.md first, look before you ship, build inside the platform floors, ship the tests with the UI); routed from a `ui` claim via discipline-modes.json; shipped by the ui-design module (task 1003324, ADR 0197)
412
413
  ├── design-sync/SKILL.md ← /design-sync — export repo design tokens + surfaces into Claude Design (token-preview bundle) and land generated UI code back as a Bongos task (ADR 0081)
413
414
  ├── feedback/SKILL.md ← /feedback — pull the latest Bongos feedback bundle (prompt.md + screenshot file paths) into a Claude Code session; backed by scripts/gds/feedback-latest.js (BV1.R13, ADR 0087)
@@ -2436,5 +2436,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2436
2436
  landed since 1.19.979 with no explicit bump. run 35889261972. (task 1002620)
2437
2437
  1.19.981 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2438
2438
  landed since 1.19.980 with no explicit bump. run 35891635199. (task 1002620)
2439
+ 1.19.982 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2440
+ landed since 1.19.981 with no explicit bump. run 35894422097. (task 1002620)
2441
+ 1.19.983 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2442
+ landed since 1.19.982 with no explicit bump. run 35896466235. (task 1002620)
2439
2443
  ---------------------------------------------------------------------------
2440
2444
  ```
@@ -403,6 +403,96 @@
403
403
  + 'Mingle pairs across roles.';
404
404
  }
405
405
 
406
+ // ---- the session-start prompt (task 1004181) -------------------------------
407
+ //
408
+ // THE COPY CONTROL EXISTS TO START A SESSION, and what it copied was the ask's
409
+ // prose ALONE (task 1003960): the words, with no request id, no task, no asker,
410
+ // and no instruction about what to do with any of it. A session opened on that
411
+ // text has the briefing and does not know it is request 11, that the request is
412
+ // still open, that only the ASKER may settle it, or that the reader wanted the
413
+ // ask's claims checked before anything was decided — so it reads five hundred
414
+ // words of someone else's certainty and acts on them.
415
+ //
416
+ // So the clipboard now carries the SKILL INVOCATION and the few facts the skill
417
+ // cannot rediscover from the text, with the ask verbatim underneath. The ask is
418
+ // untouched: same words, same line breaks, no trim beyond the outer whitespace.
419
+ // Adding a frame around it is the whole change.
420
+ //
421
+ // PURE, which is why `hallBase` is a parameter. The hall answers on two shapes
422
+ // (a /builders path on the site, or builders.<domain> as its own host) and a lib
423
+ // that read location to tell them apart could not be executed by a test.
424
+ var SKILL_COMMAND = '/collab-review';
425
+
426
+ // The one line that says what this ask is attached to, with an ABSOLUTE url —
427
+ // the prompt is pasted into a terminal, not clicked in this page, so a
428
+ // site-relative href would resolve against nothing.
429
+ function contextSentence(req, hallBase) {
430
+ var ctx = contextOf(req);
431
+ if (!ctx) return 'It names no task and no goal.';
432
+ var base = String(hallBase == null ? '' : hallBase).replace(/\/$/, '');
433
+ var href = ctx.kind === 'task'
434
+ ? base + '/task/' + ctx.id
435
+ : base + '/goals#/goal/' + ctx.id;
436
+ return 'It is about ' + ctx.kind + ' ' + ctx.id + ' — ' + href;
437
+ }
438
+
439
+ function askedOnLine(req) {
440
+ var who = askerOf(req) || 'another builder';
441
+ var when = req && req.created_at ? String(req.created_at).slice(0, 10) : '';
442
+ return when ? who + ' asked it on ' + when + '.' : who + ' asked it.';
443
+ }
444
+
445
+ // sessionStartPrompt(req, hallBase) -> the text the per-row control copies.
446
+ // Empty string for no request, so a caller can refuse to copy nothing rather
447
+ // than put an empty clipboard up as a success.
448
+ function sessionStartPrompt(req, hallBase) {
449
+ if (!req) return '';
450
+ var ask = String(req.what_is_stuck == null ? '' : req.what_is_stuck).trim();
451
+ return [
452
+ SKILL_COMMAND + ' ' + req.id,
453
+ '',
454
+ 'Work help request ' + req.id + ', which is addressed to me. ' + askedOnLine(req),
455
+ contextSentence(req, hallBase),
456
+ '',
457
+ 'Check what the ask claims against the live database and the repo before you act on'
458
+ + ' any of it, then bring me ONE decision. Do not decide it yourself, and do not try'
459
+ + ' to mark it answered — only the builder who asked can settle their own request.',
460
+ '',
461
+ '--- the ask, verbatim ---',
462
+ ask,
463
+ ].join('\n');
464
+ }
465
+
466
+ // queueStartPrompt(rows, hallBase) -> the text the block control copies: the
467
+ // whole queue, OLDEST FIRST, as a list of ids and nothing more.
468
+ //
469
+ // DELIBERATELY NOT THE PROSE. Six asks at four thousand characters each is a
470
+ // twenty-four-thousand-character paste that buries its own instruction, and the
471
+ // session can read every word of it from the route in one call. So this copies
472
+ // the manifest — what is waiting, from whom, about what — and sends the session
473
+ // to the route for the rest, which also means it works on what is open WHEN IT
474
+ // RUNS rather than on what was open when the button was pressed.
475
+ function queueStartPrompt(rows, hallBase) {
476
+ var list = (rows || []).slice().sort(byAgeOldestFirst);
477
+ if (!list.length) return '';
478
+ var lines = [SKILL_COMMAND, ''];
479
+ lines.push('Work the ' + list.length + ' open ask' + (list.length === 1 ? '' : 's')
480
+ + ' addressed to me, oldest first:');
481
+ lines.push('');
482
+ list.forEach(function (req, i) {
483
+ var ctx = contextOf(req);
484
+ var when = req.created_at ? ', ' + String(req.created_at).slice(0, 10) : '';
485
+ lines.push((i + 1) + '. help request ' + req.id
486
+ + ' — from ' + (askerOf(req) || 'another builder') + when
487
+ + (ctx ? ' — ' + ctx.kind + ' ' + ctx.id : ' — no task or goal named'));
488
+ });
489
+ lines.push('');
490
+ lines.push('Read each ask from GET /api/bongos/help-requests/for-me rather than from this'
491
+ + ' list — it is the live queue and this is only what was on screen. Check what each one'
492
+ + ' claims before acting on it, and bring me one decision at a time.');
493
+ return lines.join('\n');
494
+ }
495
+
406
496
  window.OTBCollabLib = {
407
497
  BANDS: BANDS, ageBand: ageBand, ageLabel: ageLabel,
408
498
  roomBaseline: roomBaseline, isRoomQuiet: isRoomQuiet, roomAgeLine: roomAgeLine,
@@ -413,5 +503,7 @@
413
503
  titleOf: titleOf, summaryOf: summaryOf, clipWords: clipWords, LEAD_MAX: LEAD_MAX, groupOf: groupOf,
414
504
  httpUrl: httpUrl, initialOf: initialOf, faceOf: faceOf, askers: askers, splitShown: splitShown,
415
505
  aOrAn: aOrAn, pairSentence: pairSentence,
506
+ SKILL_COMMAND: SKILL_COMMAND, sessionStartPrompt: sessionStartPrompt,
507
+ queueStartPrompt: queueStartPrompt, contextSentence: contextSentence,
416
508
  };
417
509
  })();
@@ -101,6 +101,11 @@
101
101
  the rows, so the condensed block ends on its door to the rest. */
102
102
  .cb-more { margin-top: 12px; }
103
103
 
104
+ /* "Copy all N for one session": the row control's own pill, on its own line
105
+ under the block, so the block ends on the two doors out of it — the rest of
106
+ the rows, and the whole queue in a session. */
107
+ .cb-copy-all { display: block; margin-top: 12px; }
108
+
104
109
  /* ---- the face -----------------------------------------------------------------
105
110
  A row leads with WHO: a 36px disc, the picture when the server sent one, the
106
111
  initial on the glass when it did not (people.css's .dir-avatar, at the row's
@@ -55,6 +55,12 @@
55
55
  <a href="/builders/settings">set yours in Settings</a>.</p>
56
56
  </div>
57
57
  <button class="btn-ghost cb-more" id="cb-for-me-more" type="button" aria-expanded="false" aria-controls="cb-for-me" hidden></button>
58
+ <!-- The whole queue, taken away in one press (task 1004181). The
59
+ per-row control starts a session on ONE ask; this one starts a
60
+ session on all of them, as a manifest of ids the session reads
61
+ live rather than as a wall of pasted prose. Only this block has
62
+ it: it is the one that is a to-do list. -->
63
+ <button class="cb-ask__copy cb-copy-all" id="cb-for-me-copy" type="button" hidden></button>
58
64
  </section>
59
65
 
60
66
  <section class="scroll cb-block" aria-labelledby="cb-mine-h">
@@ -108,7 +114,7 @@
108
114
  <script src="/builders/shell.js?v=2026-09-22-movefeedback1"></script>
109
115
  <script src="/builders/palette.js" defer></script>
110
116
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
111
- <script src="/builders/collab-lib.js?v=2026-09-19-agewords"></script>
112
- <script src="/builders/collab.js?v=2026-09-19-agewords"></script>
117
+ <script src="/builders/collab-lib.js?v=2026-09-23-sessionstart"></script>
118
+ <script src="/builders/collab.js?v=2026-09-23-sessionstart"></script>
113
119
  </body>
114
120
  </html>
@@ -47,6 +47,24 @@
47
47
 
48
48
  let settling = false;
49
49
 
50
+ // THE COPY CONTROL READS THE REQUEST, NOT THE PAGE (task 1004181). It used to
51
+ // lift its text out of the hidden <p> holding the full ask, which tied the
52
+ // control to the door: an ask short enough to fit on the face had no <p>, so it
53
+ // had no copy either — and those are exactly the asks a session can act on
54
+ // fastest. The rows are indexed by id as they load and the prompt is built from
55
+ // the request itself, so every row can be taken away whether or not it has more
56
+ // to show. Keyed by String(id) because the same request appears in up to three
57
+ // blocks and the API sends ids as strings.
58
+ const askById = new Map();
59
+ let forMeRows = [];
60
+
61
+ // The hall answers on two shapes — /builders on the site, or builders.<domain>
62
+ // as its own host. A pasted prompt is opened somewhere that is not this page,
63
+ // so the links in it are absolute; same rule work.js follows.
64
+ function hallBase() {
65
+ return location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
66
+ }
67
+
50
68
  // The age pill: the band picks the colour (collab.css .cb-age--*), the LABEL
51
69
  // carries the real age, so fourteen old rows read as fourteen different ages
52
70
  // instead of fourteen copies of one alarm (artist verdict, task 1004041). The
@@ -93,13 +111,18 @@
93
111
  const s = L.summaryOf(req.what_is_stuck);
94
112
  let html = `<span class="ov-row__title">${escapeHtml(s.title)}</span>`;
95
113
  if (s.lead) html += `<p class="ov-row__sub">${escapeHtml(s.lead)}</p>`;
96
- if (!s.more) return html;
97
114
  const fid = askId(key, req.id);
98
- return html + `<div class="cb-ask">
99
- <button class="cb-ask__door" type="button" data-ask-toggle aria-expanded="false" aria-controls="${fid}">Read the full ask</button>
100
- <button class="cb-ask__copy" type="button" data-ask-copy="${fid}">Copy for Session Start</button>
101
- </div>
102
- <p class="cb-ask__full" id="${fid}" hidden>${escapeHtml(s.more)}</p>`;
115
+ // The door appears only over words the reader cannot see; the copy control
116
+ // appears always, because what it hands over is no longer the visible text
117
+ // (task 1004181) — it is the request's id, its asker, its task and the
118
+ // instruction, which are absent from the face however short the ask is.
119
+ const door = s.more
120
+ ? `<button class="cb-ask__door" type="button" data-ask-toggle aria-expanded="false" aria-controls="${fid}">Read the full ask</button>`
121
+ : '';
122
+ const full = s.more ? `<p class="cb-ask__full" id="${fid}" hidden>${escapeHtml(s.more)}</p>` : '';
123
+ return html + `<div class="cb-ask">${door}
124
+ <button class="cb-ask__copy" type="button" data-ask-copy="${escapeHtml(String(req.id))}">Copy for Session Start</button>
125
+ </div>${full}`;
103
126
  }
104
127
 
105
128
  function contextChip(req) {
@@ -304,6 +327,23 @@
304
327
  </div>`;
305
328
  }
306
329
 
330
+ // The block control: present only when there is a QUEUE to take away, and it
331
+ // says the number, so a reader knows what they are about to hand over.
332
+ //
333
+ // ONE ASK IS NOT A QUEUE, so at one row the control is absent rather than
334
+ // reworded. Its first draft said "Copy this ask for a session" in that case,
335
+ // which put two different phrasings of one action on one screen — the row's
336
+ // own control, six centimetres above, does exactly this and calls it "Copy for
337
+ // Session Start". Whichever words won, the duplicate control was the real
338
+ // problem: at one row it takes away precisely what the row control takes away.
339
+ // Above one, the words are the row's own, so the page names the act once.
340
+ function paintQueueCopy() {
341
+ const btn = $('#cb-for-me-copy');
342
+ if (!btn) return;
343
+ btn.hidden = forMeRows.length < 2;
344
+ btn.textContent = `Copy all ${forMeRows.length} for Session Start`;
345
+ }
346
+
307
347
  function paintFigures(forMe, mine, crafts) {
308
348
  const host = $('#cb-figs');
309
349
  if (!host) return;
@@ -357,16 +397,28 @@
357
397
  } catch (_) { return false; }
358
398
  }
359
399
 
360
- // textContent, not innerText: the hidden <p> holds the ask verbatim including
361
- // its line breaks, and innerText on a hidden element is not guaranteed to
362
- // return them. What lands on the clipboard is exactly what the asker wrote.
400
+ // What lands on the clipboard is the SESSION START (L.sessionStartPrompt): the
401
+ // skill to run, the request id, who asked and about what, the instruction to
402
+ // check the ask before acting on it — and then the ask itself verbatim, line
403
+ // breaks and all, exactly as its author wrote it.
363
404
  function copyAsk(btn) {
364
- const full = document.getElementById(btn.getAttribute('data-ask-copy'));
365
- if (!full) return;
366
- const text = full.textContent || '';
405
+ const req = askById.get(String(btn.getAttribute('data-ask-copy')));
406
+ if (!req) return;
407
+ const text = L.sessionStartPrompt(req, hallBase());
367
408
  if (!text) return;
368
409
  writeClipboard(text).then((ok) => {
369
- toast(ok ? 'Copied — paste it in to start a session on this.' : 'Could not copy — open the full ask and copy it by hand.');
410
+ toast(ok ? 'Copied — paste it in to start a session on this ask.' : 'Could not copy — open the full ask and copy it by hand.');
411
+ });
412
+ }
413
+
414
+ // The whole queue in one go. Only "Asked of you" carries this: it is the block
415
+ // that is a to-do list, and copying the board of everyone else's asks would be
416
+ // handing a session work nobody asked this reader for.
417
+ function copyQueue() {
418
+ const text = L.queueStartPrompt(forMeRows, hallBase());
419
+ if (!text) return;
420
+ writeClipboard(text).then((ok) => {
421
+ toast(ok ? `Copied — paste it in to work all ${forMeRows.length} in one session.` : 'Could not copy — copy the asks one at a time instead.');
370
422
  });
371
423
  }
372
424
 
@@ -393,6 +445,16 @@
393
445
  const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
394
446
  const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
395
447
 
448
+ // Re-indexed on every load, not merged into: a request settled elsewhere
449
+ // must disappear from the copy path at the same moment it disappears from
450
+ // the rows, or a stale entry would keep handing a session work that is done.
451
+ askById.clear();
452
+ forMeRows = rowsOf(forMe);
453
+ [forMeRows, rowsOf(mine), rowsOf(all)].forEach((list) => {
454
+ list.forEach((req) => { askById.set(String(req.id), req); });
455
+ });
456
+ paintQueueCopy();
457
+
396
458
  paintFigures(rowsOf(forMe), rowsOf(mine), crafts);
397
459
  paintBlock('for-me', rowsOf(forMe), forMeRow, nowMs, true);
398
460
  paintBlock('mine', rowsOf(mine), mineRow, nowMs, false);
@@ -533,6 +595,8 @@
533
595
  if (door) { ev.preventDefault(); toggleAsk(door); return; }
534
596
  const copyBtn = closest('[data-ask-copy]');
535
597
  if (copyBtn) { ev.preventDefault(); copyAsk(copyBtn); return; }
598
+ const copyAll = closest('#cb-for-me-copy');
599
+ if (copyAll) { ev.preventDefault(); copyQueue(); return; }
536
600
  const mingleBtn = closest('[data-mingle]');
537
601
  if (mingleBtn) {
538
602
  ev.preventDefault();
@@ -16,7 +16,8 @@
16
16
  "provisioning",
17
17
  "disconnect",
18
18
  "core-upgrade",
19
- "env-manifest"
19
+ "env-manifest",
20
+ "repo-private"
20
21
  ],
21
22
  "migrations": true,
22
23
  "pollers": [
@@ -0,0 +1,82 @@
1
+ // modules/provisioning/routes/repo-private.js — POST /provisioning/instances/:id/repo-private
2
+ // (split (c) of task 1003078; ADR 0176).
3
+ //
4
+ // WHY. A project stood up while its repository was public reads its code with the shared key,
5
+ // which cannot read a private repository, so a plain flip would break its next deploy. This
6
+ // route only ENQUEUES the `repo-private` intent; the runner (scripts/gds/provision-repo-private.js)
7
+ // wires the project's own read-only deploy key FIRST and flips SECOND. Going public stays the
8
+ // direct core call (POST /github/repo/visibility) — it needs no wiring.
9
+ //
10
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at the
11
+ // size ratchet — the routes/disconnect.js precedent.
12
+ 'use strict';
13
+
14
+ const express = require('express');
15
+ const api = require('../../../src/module-api');
16
+ const { pool, validateOrRespond, parseId } = api;
17
+ const provisioning = require('../provisioning');
18
+ const repoPrivate = require('../repo-private');
19
+
20
+ const log = api.logger('provisioning');
21
+
22
+ // What the runner leg would refuse, answered here so the owner hears it at once instead of
23
+ // as an error note once its retries are spent. The runner keeps its own checks — the row can
24
+ // change between this read and the drain.
25
+ function unavailableReason(inst) {
26
+ if (inst.hosting_shape === 'byo-host') {
27
+ return 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.';
28
+ }
29
+ if (inst.hosting_shape !== 'standalone') {
30
+ return 'Only a project on a Cloud host can have its repository made private from here.';
31
+ }
32
+ if (inst.status !== 'active') {
33
+ return 'This project isn’t running right now, so its repository can’t be made private from here. Try again once it’s live.';
34
+ }
35
+ if (!inst.target_ref) return 'This project has no GitHub repository linked, so there is nothing to make private.';
36
+ return null;
37
+ }
38
+
39
+ module.exports = function buildRepoPrivateRouter() {
40
+ const router = express.Router();
41
+
42
+ // POST /provisioning/instances/:id/repo-private — rank: any authenticated builder, and
43
+ // OWNER ONLY: no archon override, because the runner flips with the owner's stored GitHub
44
+ // token whoever asked (owner decision, task 1004194). 404 for everyone else, so another
45
+ // owner's id is never confirmed. Takes no body. The reply is a receipt — the repository is
46
+ // still public when it is sent; the manage page re-reads GitHub once the intent settles.
47
+ router.post('/provisioning/instances/:id/repo-private', api.requireBuilder, async (req, res) => {
48
+ const id = parseId(req, res, { positiveInt: true }); // 400 bad_id + null on failure; positiveInt so id 0 is answered, not dropped
49
+ if (!id) return;
50
+ if (validateOrRespond(req, res, {})) return;
51
+ try {
52
+ const inst = await provisioning.getInstanceById(pool, id);
53
+ if (!inst || String(inst.owner_builder_id) !== String(req.builder.id)) return res.fail('instance_not_found', 404);
54
+ const why = unavailableReason(inst);
55
+ if (why) {
56
+ return res.fail('repo_private_unavailable', { status: 409, message: why,
57
+ details: { status: inst.status, hosting_shape: inst.hosting_shape } });
58
+ }
59
+ const r = await repoPrivate.enqueueRepoPrivate(pool, id, 'api:self');
60
+ if (r.conflict) {
61
+ return res.fail('intent_conflict', { status: 409,
62
+ message: `A '${r.intent.action}' run is currently ${r.intent.state} for this project — making the repository private waits for it to finish. Try again once it has.`,
63
+ details: { action: r.intent.action, state: r.intent.state } });
64
+ }
65
+ if (r.created) {
66
+ await provisioning.recordEvent(pool, {
67
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'repo-private',
68
+ detail: 'making the repository private requested by owner', actor: 'api:self',
69
+ }).catch(() => {});
70
+ }
71
+ res.json({ ok: true, queued: r.created, action: 'repo-private', status: inst.status,
72
+ message: r.created
73
+ ? 'Queued. The platform first gives this project its own read-only deploy key, then makes the repository private. This page updates when it’s done.'
74
+ : 'Making the repository private is already under way for this project.' });
75
+ } catch (err) {
76
+ log.error('[provisioning] POST /provisioning/instances/:id/repo-private', err);
77
+ res.fail('repo_private_failed', { status: 500, message: 'internal error' });
78
+ }
79
+ });
80
+
81
+ return router;
82
+ };
@@ -762,20 +762,16 @@ summary{min-height:24px;padding:3px 0;}
762
762
  </div>
763
763
 
764
764
  <!-- live since task 1003045: reads the repo's real visibility from GitHub
765
- with the owner's own repository credential. Flip-to-PUBLIC only — the
766
- private direction is refused (server + copy) until the private-repo
767
- deploy fix lands, because the deploy pull cannot read a private repo
768
- (walk finding N4). -->
765
+ with the owner's own repository credential. Going public is a direct
766
+ call; going private (task 1003078) is QUEUED, because the project's own
767
+ deploy key must be wired before the flip or its next deploy breaks
768
+ (walk finding N4, ADR 0176). -->
769
769
  <div class="card mSec" id="mRepoVisibility">
770
770
  <h2>Repository visibility</h2>
771
771
  <div id="mRepoVis"><p class="finePrint">Checking the repository on GitHub…</p></div>
772
- <!-- the limitation itself, one click away (grade follow-up task 1003077):
773
- the refusal above states it; this fold explains it and links the
774
- tracked fix. The hall link is apex-only — a child instance keeps the
775
- explanation, not another platform's task tracker. -->
776
772
  <details class="finePrint" id="mRepoVisWhy">
777
- <summary>Why can’t it be private yet?</summary>
778
- <p>This project’s deploys read its code from GitHub with an older key that only works on public repositories — so making the repository private would break the next deploy. New standups fix this: a private repository now gets the project’s own read-only deploy key, wired automatically<!--apex-only:start--> — shipped as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall<!--apex-only:end-->. A project set up before that still reads with the older key, so the switch stays off until existing projects are re-wired.</p>
773
+ <summary>What making it private does</summary>
774
+ <p>The platform first gives this project its own read-only deploy key and switches its deploys over to it, and only then makes the repository private — in that order, so no deploy ever tries to read a private repository with a key that can’t. The change is made with your connected GitHub account, so that account needs admin rights on the repository. It works for a project on a Cloud host; a project on your own host is yours to change on GitHub directly. You can make it public again from here at any time.</p>
779
775
  </details>
780
776
  </div>
781
777
 
@@ -5149,7 +5145,8 @@ summary{min-height:24px;padding:3px 0;}
5149
5145
  teardown: { names: ['Teardown queued', 'Winding down', 'Torn down'], cls: 'flowDown' },
5150
5146
  address: { names: ['Address queued', 'Wiring the address', 'Answering there'], cls: '' },
5151
5147
  settings: { names: ['Change saved', 'Restarting to apply it', 'Applied'], cls: '' },
5152
- restart: { names: ['Restart queued', 'Restarting', 'Answering again'], cls: '' }
5148
+ restart: { names: ['Restart queued', 'Restarting', 'Answering again'], cls: '' },
5149
+ repo: { names: ['Change queued', 'Setting up its deploy key', 'Private'], cls: '' }
5153
5150
  };
5154
5151
  function stripMarkup(flow, stageNow, stagesDone, note, extraCls) {
5155
5152
  var f = FLOWS[flow];
@@ -5191,6 +5188,10 @@ summary{min-height:24px;padding:3px 0;}
5191
5188
  if (a === 'restart') return inst.open_intent.state === 'running'
5192
5189
  ? stripMarkup('restart', 1, 1, 'Restarting the project — it goes down and comes back up.')
5193
5190
  : stripMarkup('restart', 0, 0, 'A restart is queued — this card updates when it starts.');
5191
+ /* task 1003078: the deploy key is wired BEFORE the flip — the strip says which half is running */
5192
+ if (a === 'repo-private') return inst.open_intent.state === 'running'
5193
+ ? stripMarkup('repo', 1, 1, 'Making the repository private — setting up the project’s own deploy key first.')
5194
+ : stripMarkup('repo', 0, 0, 'Making the repository private is queued — this card updates when it starts.');
5194
5195
  if (a === 'provision') return stripMarkup(s === 'torn_down' ? 'revive' : 'standup', 0, 0, (s === 'torn_down' ? 'A revive is queued' : 'A standup is queued') + ' — this card updates when it starts.');
5195
5196
  return stripMarkup('standup', 2, 2, 'A ' + esc(a) + ' run is queued — this card updates when it lands.');
5196
5197
  }
@@ -5215,6 +5216,10 @@ summary{min-height:24px;padding:3px 0;}
5215
5216
  var n = String(note || '');
5216
5217
  if (/^repository not found:/i.test(n)) return 'We couldn’t find the project’s repository on GitHub even with the access you granted — it may have been renamed or deleted, or the access was lost.';
5217
5218
  if (/^repository access is not admin:/i.test(n)) return 'Your GitHub access can read the repository but can’t manage its deploy keys — a repository admin needs to raise your access (or make the repository public).';
5219
+ /* the repo-private runner leg's own sentences (task 1003078) — the project is live */
5220
+ if (/made private[\s\S]*credential/i.test(n)) return 'Making the repository private didn’t finish — the project’s GitHub access needs reconnecting first. Its repository is still public.';
5221
+ if (/is still public|made private|left public/i.test(n)) return 'Making the repository private didn’t finish — it’s still public.';
5222
+ if (/^deploy-key setup failed/i.test(n)) return 'Setting up the project’s own read-only deploy key on GitHub didn’t finish.';
5218
5223
  if (/repository not found/i.test(n)) return 'We couldn’t push the starter code to the repository — it wasn’t found on GitHub. A private repository looks “not found” too — if yours is private, sign in again to re-grant repository access, then retry.';
5219
5224
  if (/mkdir|no such file or directory|read-only file system/i.test(n)) return 'The server couldn’t prepare a workspace for the project.';
5220
5225
  if (/(push|clone|github).*permission|permission.*(push|clone|repo)/i.test(n)) return 'We couldn’t reach the repository — the account that set it up doesn’t have permission.';
@@ -5617,6 +5622,7 @@ summary{min-height:24px;padding:3px 0;}
5617
5622
  only, so a queued settings push shows up on the tick without a reload */
5618
5623
  renderVisState(inst);
5619
5624
  renderJoinState(inst);
5625
+ renderRepoVisState(inst);
5620
5626
  /* its planet (task 1003347) reads off the row — fetch-free too; a pick in
5621
5627
  progress survives the tick because only a different project re-renders it */
5622
5628
  renderPick(inst);
@@ -5853,6 +5859,7 @@ summary{min-height:24px;padding:3px 0;}
5853
5859
  $('mChip').innerHTML = '';
5854
5860
  $('mOrb').innerHTML = '';
5855
5861
  }
5862
+ if (!keepRepo) { mRepoPrivSeen = false; mRepoPrivState = ''; }
5856
5863
  var asked = manageId; /* a stale answer must never paint a newer project */
5857
5864
  fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId), { credentials: 'same-origin' })
5858
5865
  .then(function (res) {
@@ -5922,14 +5929,16 @@ summary{min-height:24px;padding:3px 0;}
5922
5929
  setInterval(manageChipTick, REFRESH_MS);
5923
5930
 
5924
5931
  /* ── repo visibility (task 1003045): read live from GitHub with the owner's
5925
- own repository credential; flip-to-PUBLIC only. The private direction is
5926
- refused by the server — and unbuilt here — until the private-repo deploy
5927
- fix lands: the deploy pull runs as the shared deploy key, which cannot
5928
- read a private repo (walk finding N4), so flipping private would break
5929
- the project's next deploy. Every claim below is what GitHub answered for
5930
- THIS account — never a cached or guessed state. ── */
5932
+ own repository credential. Going public is a direct call. Going private
5933
+ (task 1003078) is QUEUED on the provisioning route: the runner wires the
5934
+ project's own deploy key first, because a project stood up public reads
5935
+ with the shared key, which cannot read a private repo (walk finding N4).
5936
+ Every claim below is what GitHub answered for THIS account — never a
5937
+ cached or guessed state; the queue's receipt is not a new visibility. ── */
5931
5938
 
5932
5939
  var manageRepo = null; /* GitHub's last answer for the manage view's repo */
5940
+ var mRepoPrivSeen = false; /* a make-private run was open — ask GitHub again once it settles */
5941
+ var mRepoPrivState = ''; /* the in-flight line last painted: '', 'pending' or 'running' */
5933
5942
 
5934
5943
  function repoVisReconnect() {
5935
5944
  return API + '/auth/web/start?repos=1&return=' +
@@ -5947,14 +5956,67 @@ summary{min-height:24px;padding:3px 0;}
5947
5956
  '<div class="addr">This repository is <b>private</b> — only accounts it’s shared with can read its code.</div>' +
5948
5957
  (snagged
5949
5958
  ? '<div class="projMsg err">A private repository deploys through the project’s own read-only deploy key, wired at standup. If the standup couldn’t read it, retry it from the card above — or make the repository public.</div>'
5950
- : '<p class="finePrint">The platform reads it through this project’s own read-only deploy key, set up at standup. Nothing to do here.</p>') +
5959
+ : '<p class="finePrint">The platform reads it through this project’s own read-only deploy key. Nothing to do here.</p>') +
5951
5960
  (repo.admin
5952
5961
  ? '<div class="actions"><button type="button" class="btn small ghost" data-repo-public>Make it public</button></div>'
5953
5962
  : '<p class="finePrint">Your GitHub account can’t change this repository’s settings.</p>'));
5954
5963
  } else {
5955
- repoVisMsg(
5956
- '<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
5957
- '<p class="finePrint">Making it private isn’t possible from here yet — see below for why.</p>');
5964
+ repoVisMsg('<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
5965
+ (repoPrivOpen(manageInst)
5966
+ ? '<p class="finePrint">' + (manageInst.open_intent.state === 'running'
5967
+ ? 'Making it private — the platform is giving this project its own deploy key first.'
5968
+ : 'Making it private is queued — the platform gives this project its own deploy key first. Until then it stays public.') + '</p>'
5969
+ : repoPrivDoor(manageInst, repo)) +
5970
+ /* the make-private receipt's slot, painted WITH the state (the #mVisMsg idiom) —
5971
+ a sibling div would land on this one: every .mSec>div takes the same grid cell */
5972
+ '<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"></div>');
5973
+ }
5974
+ }
5975
+
5976
+ /* fetch-free, from open_intent only (renderManage calls it on every repaint) */
5977
+ function repoPrivOpen(inst) {
5978
+ return !!(inst && inst.open_intent && inst.open_intent.action === 'repo-private');
5979
+ }
5980
+
5981
+ /* the one door to private, or the sentence saying why there is none here — the
5982
+ route refuses the same cases; this only keeps a button off a dead end */
5983
+ function repoPrivDoor(inst, repo) {
5984
+ if (!inst) return '';
5985
+ var why = inst.hosting_shape === 'byo-host' ? 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.'
5986
+ : inst.hosting_shape !== 'standalone' ? 'Only a project on a Cloud host can have its repository made private from here.'
5987
+ : inst.status !== 'active' ? 'You can make it private once the project is running.'
5988
+ : !repo.admin ? 'Your GitHub account can’t change this repository’s settings.'
5989
+ : '';
5990
+ if (why) return '<p class="finePrint">' + why + '</p>';
5991
+ return '<p class="finePrint">Making it private gives this project its own deploy key first, so its deploys keep working.</p>' +
5992
+ '<div class="actions"><button type="button" class="btn small ghost" data-repo-private>Make it private</button></div>';
5993
+ }
5994
+
5995
+ function repoVisNote(cls, html) {
5996
+ var box = $('mRepoVisMsg');
5997
+ if (!box) {
5998
+ if (!html) return;
5999
+ $('mRepoVis').insertAdjacentHTML('beforeend', '<div class="projMsg" id="mRepoVisMsg" role="status" aria-live="polite"></div>');
6000
+ box = $('mRepoVisMsg');
6001
+ }
6002
+ box.className = 'projMsg' + (cls ? ' ' + cls : '');
6003
+ box.innerHTML = html;
6004
+ }
6005
+
6006
+ /* the in-flight line is repainted only when the run's state MOVES, so the tick
6007
+ never flickers it; once the run settles, GitHub is asked again — the only
6008
+ source that can say the flip took (the renderVisState contract) */
6009
+ function renderRepoVisState(inst) {
6010
+ var now = repoPrivOpen(inst) ? inst.open_intent.state : '';
6011
+ if (now) mRepoPrivSeen = true;
6012
+ if (now !== mRepoPrivState) {
6013
+ mRepoPrivState = now;
6014
+ if (manageRepo && !manageRepo.private) renderRepoVis(manageRepo);
6015
+ }
6016
+ if (!now && mRepoPrivSeen) {
6017
+ mRepoPrivSeen = false;
6018
+ repoVisNote('', '');
6019
+ loadRepoVisibility(inst);
5958
6020
  }
5959
6021
  }
5960
6022
 
@@ -6034,12 +6096,58 @@ summary{min-height:24px;padding:3px 0;}
6034
6096
  });
6035
6097
  }
6036
6098
 
6099
+ function askRepoPrivate() {
6100
+ var act = document.querySelector('#mRepoVis .actions');
6101
+ if (!act) return;
6102
+ act.innerHTML = '<div class="confirmRow">' +
6103
+ '<span class="plain">Make it private? Only accounts it’s shared with will be able to read the code.</span>' +
6104
+ '<button type="button" class="btn small" data-repo-private-yes>Yes, make it private</button>' +
6105
+ '<button type="button" class="linklike" data-repo-private-no>Keep it public</button></div>';
6106
+ }
6107
+
6108
+ /* the reply is a RECEIPT — the repository is still public when it lands. The
6109
+ in-flight line reads open_intent, so the manage row is re-read, never patched */
6110
+ function doRepoPrivate() {
6111
+ if (!manageInst) return;
6112
+ var btn = document.querySelector('#mRepoVis [data-repo-private-yes]');
6113
+ if (btn) btn.disabled = true;
6114
+ repoVisNote('', 'Queueing…');
6115
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/repo-private', {
6116
+ method: 'POST', credentials: 'same-origin'
6117
+ }).then(function (res) {
6118
+ return jsonOrEmpty(res).then(function (data) {
6119
+ if (res.ok) {
6120
+ /* the receipt proves a run is OPEN — never that the repository changed. Record
6121
+ that much, so the 5s watch follows it and a repaint from the stale row cannot
6122
+ drop it; the re-read below swaps in the server's own open_intent */
6123
+ if (!repoPrivOpen(manageInst)) manageInst.open_intent = { action: 'repo-private', state: 'pending' };
6124
+ renderRepoVisState(manageInst);
6125
+ repoVisNote('ok', esc(data.message || 'Queued.'));
6126
+ return;
6127
+ }
6128
+ var e = apiError(data, res.status);
6129
+ if (manageRepo) renderRepoVis(manageRepo);
6130
+ repoVisNote('err', res.status === 401 ? signInAgain()
6131
+ : res.status === 404 ? 'Only the project’s owner can make its repository private.'
6132
+ : esc(e.message || 'The change could not be queued — try again in a moment.'));
6133
+ });
6134
+ }).catch(function () {
6135
+ if (manageRepo) renderRepoVis(manageRepo);
6136
+ repoVisNote('err', 'Couldn’t reach the platform — check your connection and try again.');
6137
+ }).then(function () {
6138
+ loadManage(true, true);
6139
+ });
6140
+ }
6141
+
6037
6142
  $('mRepoVis').addEventListener('click', function (ev) {
6038
6143
  var t = ev.target.closest && ev.target.closest('button');
6039
6144
  if (!t) return;
6040
6145
  if (t.hasAttribute('data-repo-public')) return askRepoPublic();
6041
6146
  if (t.hasAttribute('data-repo-public-yes')) return doRepoPublic();
6042
6147
  if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
6148
+ if (t.hasAttribute('data-repo-private')) return askRepoPrivate();
6149
+ if (t.hasAttribute('data-repo-private-yes')) return doRepoPrivate();
6150
+ if (t.hasAttribute('data-repo-private-no')) { if (manageRepo) renderRepoVis(manageRepo); }
6043
6151
  });
6044
6152
 
6045
6153
  /* ── on the public map (task 1002335, BV1.R16): the publish gate ──────────