@bongos/core 1.19.1081 → 1.20.1

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 (251) hide show
  1. package/.bongos-core.json +526 -191
  2. package/.claude/skills/planning-session/SKILL.md +6 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +32 -0
  5. package/clients/bongos-client/index.cjs +32 -0
  6. package/clients/bongos-client/index.d.ts +51 -6
  7. package/clients/bongos-client/index.mjs +32 -0
  8. package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
  9. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  10. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
  11. package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
  12. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  13. package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
  14. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
  15. package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +851 -19
  18. package/docs/api-reference.md +33 -9
  19. package/docs/architecture.md +6 -2
  20. package/docs/copy-inventory.md +620 -553
  21. package/docs/copy-registry.json +1467 -820
  22. package/docs/file-map.md +2 -0
  23. package/docs/module-api-changelog.md +6 -0
  24. package/docs/modules-contract.md +1 -0
  25. package/docs/page-inventory.json +38 -4
  26. package/docs/page-readings.json +1674 -1566
  27. package/migrations/core_260_goals_working_area.sql +63 -0
  28. package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
  29. package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
  30. package/modules/agents/lib/validate.js +43 -0
  31. package/modules/autonomy/gauge.js +38 -2
  32. package/modules/grading/grader-subagent.js +37 -2
  33. package/modules/grading/grader-workers/reader.js +65 -5
  34. package/modules/hall-ui/public/approval-queue.css +33 -7
  35. package/modules/hall-ui/public/approval-queue.js +9 -2
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/brand-holes.js +121 -0
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/collab.js +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +6 -1
  44. package/modules/hall-ui/public/deploy.js +22 -8
  45. package/modules/hall-ui/public/diagrams.html +1 -1
  46. package/modules/hall-ui/public/dom-utils.js +20 -0
  47. package/modules/hall-ui/public/drachmae.html +1 -1
  48. package/modules/hall-ui/public/fleet.html +1 -1
  49. package/modules/hall-ui/public/gate.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/idea.js +28 -0
  54. package/modules/hall-ui/public/ideas.html +1 -1
  55. package/modules/hall-ui/public/ideas.js +19 -3
  56. package/modules/hall-ui/public/index.html +5 -1
  57. package/modules/hall-ui/public/modules.html +1 -1
  58. package/modules/hall-ui/public/primer.html +1 -1
  59. package/modules/hall-ui/public/profile-nudge.js +21 -4
  60. package/modules/hall-ui/public/profile.html +1 -1
  61. package/modules/hall-ui/public/profile.js +1 -1
  62. package/modules/hall-ui/public/project-settings.html +1 -1
  63. package/modules/hall-ui/public/ranks.html +1 -1
  64. package/modules/hall-ui/public/roadmap.html +1 -1
  65. package/modules/hall-ui/public/roster.html +1 -1
  66. package/modules/hall-ui/public/sessions.html +1 -1
  67. package/modules/hall-ui/public/settings.html +19 -30
  68. package/modules/hall-ui/public/settings.js +24 -177
  69. package/modules/hall-ui/public/settings.states.json +1 -1
  70. package/modules/hall-ui/public/shell.js +4 -2
  71. package/modules/hall-ui/public/studio.css +14 -8
  72. package/modules/hall-ui/public/studio.html +5 -4
  73. package/modules/hall-ui/public/task.html +1 -1
  74. package/modules/hall-ui/public/thinking.css +6 -3
  75. package/modules/hall-ui/public/thinking.html +1 -1
  76. package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
  77. package/modules/hall-ui/public/tweak-editor.css +142 -11
  78. package/modules/hall-ui/public/tweak-editor.html +25 -9
  79. package/modules/hall-ui/public/tweak-editor.js +243 -10
  80. package/modules/hall-ui/public/watch.html +1 -1
  81. package/modules/hall-ui/public/work.html +1 -1
  82. package/modules/hall-ui/records/approval-queue.md +6 -0
  83. package/modules/hall-ui/records/tweak-editor.md +6 -0
  84. package/modules/ideas/ratify.js +36 -8
  85. package/modules/ideas/routes/ratify-goal.js +6 -0
  86. package/modules/lifecycle/db-analytics.js +79 -10
  87. package/modules/lifecycle/db-claims.js +48 -1
  88. package/modules/lifecycle/db-deps-criteria.js +94 -40
  89. package/modules/lifecycle/db-goals.js +4 -1
  90. package/modules/lifecycle/db-grade.js +9 -1
  91. package/modules/lifecycle/db-relevance-flags.js +183 -0
  92. package/modules/lifecycle/db-versions.js +4 -3
  93. package/modules/lifecycle/db.js +19 -0
  94. package/modules/lifecycle/done-when.js +5 -3
  95. package/modules/lifecycle/est-advisory.js +31 -4
  96. package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
  97. package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
  98. package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
  99. package/modules/lifecycle/module.json +2 -1
  100. package/modules/lifecycle/routes/claims.js +8 -1
  101. package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
  102. package/modules/lifecycle/routes/goals.js +1 -1
  103. package/modules/lifecycle/routes/tasks.js +27 -24
  104. package/modules/lifecycle/routes/visuals.js +9 -0
  105. package/modules/lifecycle/ship-preflight.js +7 -11
  106. package/modules/lifecycle/task-visuals.js +109 -5
  107. package/modules/npm-release/module.json +2 -1
  108. package/modules/npm-release/preview/commands.js +81 -0
  109. package/modules/npm-release/preview/divert.js +95 -0
  110. package/modules/npm-release/preview/env.js +78 -0
  111. package/modules/npm-release/preview/proxy.js +107 -0
  112. package/modules/npm-release/preview/runtime.js +83 -0
  113. package/modules/npm-release/preview/supervisor.js +175 -0
  114. package/modules/npm-release/public/work.js +65 -1
  115. package/modules/npm-release/routes/preview.js +133 -0
  116. package/modules/provisioning/app-status.js +83 -0
  117. package/modules/provisioning/module.json +2 -1
  118. package/modules/provisioning/pollers/app-liveness.js +110 -0
  119. package/modules/provisioning/render-lookup.js +68 -0
  120. package/modules/provisioning/render-standup.js +44 -1
  121. package/modules/provisioning/routes/render-standup.js +81 -6
  122. package/modules/provisioning/starter-bundles.js +4 -1
  123. package/modules/public-landing/public/account-private.states.json +14 -0
  124. package/modules/public-landing/public/account.html +513 -0
  125. package/modules/public-landing/public/account.probes.json +37 -0
  126. package/modules/public-landing/public/account.states.json +16 -0
  127. package/modules/public-landing/public/index.html +5 -2
  128. package/modules/public-landing/public/projects.html +661 -18
  129. package/modules/public-landing/public/projects.states.json +8 -1
  130. package/modules/render-deploy/deploys.js +203 -0
  131. package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
  132. package/modules/render-deploy/module.json +25 -0
  133. package/modules/render-deploy/public/deploy.css +10 -0
  134. package/modules/render-deploy/public/deploy.js +249 -0
  135. package/modules/render-deploy/render.js +56 -0
  136. package/modules/render-deploy/routes/act.js +88 -0
  137. package/modules/render-deploy/routes/app.js +65 -0
  138. package/modules/render-deploy/routes/history.js +38 -0
  139. package/modules/specialities/db.js +27 -8
  140. package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
  141. package/modules/specialities/routes/specialities.js +51 -3
  142. package/modules/specialities/skills.js +94 -0
  143. package/modules/specialities/specialities.js +56 -9
  144. package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
  145. package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
  146. package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
  147. package/modules/ui-design/kit/lib.js +3 -1
  148. package/modules/ui-design/kit/serve.js +96 -0
  149. package/package-lock.json +2 -2
  150. package/package.json +1 -1
  151. package/release-notes.json +163 -0
  152. package/scripts/gds/agents-sync.js +13 -3
  153. package/scripts/gds/autobongos-run.js +80 -2
  154. package/scripts/gds/autobongos-service.cmd +12 -0
  155. package/scripts/gds/copy-apply.js +14 -0
  156. package/scripts/gds/dev-box-guard.js +3 -2
  157. package/scripts/gds/fitness.js +9 -0
  158. package/scripts/gds/grade-correlation-audit.js +42 -12
  159. package/scripts/gds/grade-replay.js +1 -1
  160. package/scripts/gds/provision-core-upgrade.js +38 -7
  161. package/scripts/gds/provision-pin-key.js +176 -0
  162. package/scripts/gds/provision-render.js +62 -31
  163. package/scripts/gds/provision-teardown.js +7 -2
  164. package/scripts/gds/provision-units.js +31 -0
  165. package/scripts/gds/provision.js +10 -10
  166. package/scripts/gds/publish-manifest.js +1 -0
  167. package/scripts/gds/render-api.js +19 -0
  168. package/scripts/gds/render-payload.js +39 -65
  169. package/scripts/gds/run-unit-tests.js +10 -0
  170. package/scripts/gds/ship-finish.js +19 -20
  171. package/scripts/gds/smoke-dependencies.sh +37 -8
  172. package/scripts/gds/status.js +12 -4
  173. package/scripts/gds/upgrade.js +2 -2
  174. package/scripts/public-mirror-export.js +7 -1
  175. package/src/bongos/module-scope-map.js +13 -0
  176. package/src/bongos/serve-internal.js +3 -1
  177. package/src/module-api.js +10 -1
  178. package/src/platform-server.js +39 -0
  179. package/tests/account_privacy_flags.mjs +10 -6
  180. package/tests/agents_authoring.mjs +15 -1
  181. package/tests/agents_sync.mjs +106 -5
  182. package/tests/agents_validate.mjs +45 -0
  183. package/tests/api_path_404.mjs +6 -0
  184. package/tests/autobongos_cadence.mjs +7 -0
  185. package/tests/autobongos_loop.mjs +136 -1
  186. package/tests/autonomy_gauge.mjs +62 -0
  187. package/tests/blocker_hall_live_proof.mjs +275 -0
  188. package/tests/claim_gate_ci_unblock.mjs +116 -0
  189. package/tests/claim_gate_rebase.mjs +15 -3
  190. package/tests/collab_page.mjs +10 -0
  191. package/tests/conductor_main_e2e.mjs +97 -0
  192. package/tests/core_262_drop_box_blocked_db.mjs +143 -0
  193. package/tests/core_upgrade_runner.mjs +1 -0
  194. package/tests/dependency_writes_atomic.mjs +191 -0
  195. package/tests/deploy_page_projects.mjs +2 -1
  196. package/tests/effective_visibility_predicate.mjs +15 -0
  197. package/tests/est_advisory.mjs +27 -0
  198. package/tests/goal_map_page.mjs +26 -0
  199. package/tests/goal_task_relevance.mjs +518 -0
  200. package/tests/goal_working_area.mjs +146 -0
  201. package/tests/grade_attempts.mjs +61 -0
  202. package/tests/grade_attribution.mjs +23 -0
  203. package/tests/grade_correlation_audit.mjs +54 -1
  204. package/tests/grader_reader_lens.mjs +55 -2
  205. package/tests/grader_root_outage.mjs +6 -1
  206. package/tests/grader_subagent_tools_arg.mjs +102 -0
  207. package/tests/hall_approval_queue.mjs +54 -2
  208. package/tests/hall_settings_world.mjs +3 -1
  209. package/tests/hall_tweak_editor.mjs +316 -11
  210. package/tests/hub_account_page.mjs +394 -0
  211. package/tests/idea_detail_page.mjs +64 -0
  212. package/tests/idea_goal_ratification.mjs +64 -0
  213. package/tests/idea_spark_hall.mjs +60 -9
  214. package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
  215. package/tests/landing_page.mjs +6 -3
  216. package/tests/module-scope-map.mjs +31 -8
  217. package/tests/module_api.mjs +1 -0
  218. package/tests/module_loader.mjs +1 -1
  219. package/tests/nav_permission_atoms.mjs +1 -1
  220. package/tests/no_phantom_mirror_workflow.mjs +24 -0
  221. package/tests/npm_release_preview_commands.mjs +89 -0
  222. package/tests/npm_release_preview_divert.mjs +164 -0
  223. package/tests/npm_release_preview_env.mjs +100 -0
  224. package/tests/npm_release_preview_proxy.mjs +162 -0
  225. package/tests/npm_release_preview_routes.mjs +208 -0
  226. package/tests/npm_release_preview_supervisor.mjs +203 -0
  227. package/tests/pin_write_key.mjs +383 -0
  228. package/tests/planning_session_skill.mjs +27 -0
  229. package/tests/platform_boot.mjs +71 -0
  230. package/tests/profile_nudge_links.mjs +86 -0
  231. package/tests/profile_ui_cross_project.mjs +1 -1
  232. package/tests/projects_hub.mjs +2 -2
  233. package/tests/projects_hub_app_status.mjs +249 -0
  234. package/tests/projects_hub_app_step.mjs +282 -52
  235. package/tests/projects_hub_render_connect.mjs +295 -0
  236. package/tests/provision_render.mjs +121 -28
  237. package/tests/provision_settings_apply.mjs +62 -0
  238. package/tests/provisioning_app_status.mjs +204 -0
  239. package/tests/provisioning_render_route.mjs +126 -6
  240. package/tests/render_check.mjs +2 -1
  241. package/tests/render_deploy.mjs +495 -0
  242. package/tests/ship_preflight.mjs +61 -8
  243. package/tests/smoke_dependencies_witness.mjs +46 -0
  244. package/tests/speciality_skills.mjs +214 -0
  245. package/tests/studio_room_height.mjs +70 -0
  246. package/tests/task_detail_includes.mjs +10 -0
  247. package/tests/task_visuals_instance_root.mjs +150 -0
  248. package/tests/tweak_batch_apply.mjs +21 -0
  249. package/tests/ui_design_kit.mjs +36 -4
  250. package/tests/upgrade.mjs +1 -1
  251. package/tests/upgrade_persist_pin.mjs +24 -0
@@ -0,0 +1,295 @@
1
+ // tests/projects_hub_render_connect.mjs — Connect Render from an existing project's page,
2
+ // EXECUTED (task 1004184, ADR 0345).
3
+ //
4
+ // A project whose app has no platform yet (made before the wizard's app step, or whose
5
+ // owner chose "Decide later") gets Connect Render on its "Where its app runs" card. It is
6
+ // the wizard's own flow — its markup cloned, its logic renderConnect() — so this slices the
7
+ // page's region and the shared flow out of projects.html by their sentinels and runs them
8
+ // in `vm` over a small fake DOM built from the wizard's real Render markup.
9
+ //
10
+ // The load-bearing assertions:
11
+ // • it is offered only on the owner's own "no platform" answer, and only for a project
12
+ // the server would take it for; a project that never connects is left as it was
13
+ // • it is the wizard's flow, not a second one: the same ids, prefixed, and the same checks
14
+ // • the key is sent once, in a POST body to THIS project, and forgotten at once — on a
15
+ // refusal too — and the card then shows the app's own progress
16
+ // • moving to another project forgets every answer
17
+ //
18
+ // Run: node --test --test-reporter=tap tests/projects_hub_render_connect.mjs
19
+
20
+ import test from 'node:test';
21
+ import assert from 'node:assert/strict';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import vm from 'node:vm';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
29
+
30
+ function fence(start, end) {
31
+ const from = HUB.indexOf(start), to = HUB.indexOf(end, from);
32
+ assert.ok(from > -1 && to > from, `fenced: ${start.trim()}`);
33
+ return HUB.slice(from, to);
34
+ }
35
+ const CONNECT = fence(' /* ── connecting Render (task 1004352', ' /* end of connecting Render */');
36
+ const PAGE = fence(' var stopAppWatch = null;', " /* end of Connect Render from the project's page */");
37
+ const APPEL = (HUB.match(/function appEl\(tag, cls, text\) \{[\s\S]*?\n \}/) || [''])[0];
38
+ assert.ok(APPEL, 'the page\'s own appEl() is lifted');
39
+ const SETUP = (HUB.match(/<div class="renderSetup" id="renderSetup" hidden>[\s\S]*?<div class="hint" id="h-render" role="status">&nbsp;<\/div>/) || [''])[0];
40
+ assert.ok(SETUP, 'the wizard\'s Render markup is found');
41
+ const CARD = (HUB.match(/<div class="card mSec" id="mAppCard" hidden>[\s\S]*?\n <\/div>\n/) || [''])[0];
42
+
43
+ const KEY = 'rnd_abcdefghijklmnopqrstuvwx';
44
+ const TEA_A = 'tea-aaaaaaaa1111';
45
+ const LOOKUP = '/api/bongos/provisioning/render/lookup';
46
+ const RENDER = '/api/bongos/provisioning/instances/7/render';
47
+ const SERVICES = { services: [{ id: 'srv-aaaaaaaa1111', name: 'shop', repo: 'https://github.com/example-owner/shop', url: null }] };
48
+ const INST = { id: 7, slug: 'shop', status: 'active', target_ref: 'example-owner/shop' };
49
+ const flush = async () => { for (let i = 0; i < 8; i++) await new Promise((r) => setTimeout(r, 0)); };
50
+
51
+ // ── a fake DOM: what the region and the flow use ──
52
+ const escText = (x) => String(x).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
53
+ class Text { constructor(t) { this.text = String(t); } html() { return escText(this.text); } get textContent() { return this.text; } }
54
+ class El {
55
+ constructor(tag, id) { this.tagName = tag; this.id = id || ''; this.children = []; this.attrs = {}; this.listeners = {};
56
+ this.hidden = false; this.value = ''; this.className = ''; this.checked = false; this.disabled = false; this.type = ''; this.name = ''; this.focused = false; }
57
+ appendChild(c) { this.children.push(c); return c; }
58
+ insertBefore(c, ref) { const i = this.children.indexOf(ref); if (i < 0) this.children.push(c); else this.children.splice(i, 0, c); return c; }
59
+ get firstChild() { return this.children[0] || null; }
60
+ setAttribute(k, v) { this.attrs[k] = String(v); }
61
+ getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
62
+ set textContent(v) { this.children = v === '' || v == null ? [] : [new Text(v)]; }
63
+ get textContent() { return this.children.map((c) => c.textContent).join(''); }
64
+ addEventListener(ev, fn) { (this.listeners[ev] = this.listeners[ev] || []).push(fn); }
65
+ fire(ev, event) { (this.listeners[ev] || []).forEach((fn) => fn.call(this, event || { target: this })); }
66
+ focus() { this.focused = true; }
67
+ all() { return [this, ...this.children.filter((c) => c instanceof El).flatMap((c) => c.all())]; }
68
+ querySelectorAll(sel) {
69
+ const kids = this.all().slice(1);
70
+ if (sel === '[id]') return kids.filter((e) => e.id);
71
+ if (sel === '[aria-describedby]') return kids.filter((e) => e.getAttribute('aria-describedby') != null);
72
+ throw new Error('unexpected selector ' + sel);
73
+ }
74
+ cloneNode() {
75
+ const c = new El(this.tagName, this.id);
76
+ Object.assign(c.attrs, this.attrs); c.className = this.className; c.hidden = this.hidden; c.value = this.value; c.type = this.type;
77
+ this.children.forEach((k) => c.appendChild(k instanceof El ? k.cloneNode(true) : new Text(k.text)));
78
+ return c;
79
+ }
80
+ html() {
81
+ const a = [this.id ? ` id="${this.id}"` : '', this.className ? ` class="${this.className}"` : '', this.type ? ` type="${this.type}"` : '', this.name ? ` name="${this.name}"` : '',
82
+ ...Object.entries(this.attrs).map(([k, v]) => ` ${k}="${v}"`), this.checked ? ' checked' : '', this.disabled ? ' disabled' : ''].join('');
83
+ return `<${this.tagName}${a}>${this.children.map((c) => c.html()).join('')}</${this.tagName}>`;
84
+ }
85
+ }
86
+
87
+ // The wizard's #renderSetup, one node per id in the REAL markup (flat: the flow only ever
88
+ // finds its elements by id), so a field added to the step is here the day it lands.
89
+ function wizardSetup() {
90
+ const root = new El('div', 'renderSetup');
91
+ root.className = 'renderSetup'; root.hidden = true;
92
+ for (const m of SETUP.matchAll(/<(\w+)[^>]*\sid="([^"]+)"([^>]*)>/g)) {
93
+ if (m[2] === 'renderSetup') continue;
94
+ const e = new El(m[1], m[2]);
95
+ const d = (m[0].match(/aria-describedby="([^"]+)"/) || [])[1];
96
+ if (d) e.setAttribute('aria-describedby', d);
97
+ if (m[1] === 'input') e.type = (m[0].match(/type="([^"]+)"/) || [])[1] || 'text';
98
+ root.appendChild(e);
99
+ }
100
+ return root;
101
+ }
102
+
103
+ function load({ answers = {} } = {}) {
104
+ const roots = [wizardSetup()];
105
+ const card = new El('div', 'mAppCard'); card.hidden = true;
106
+ ['mApp', 'mAppConnect', 'mAppConnectOpen', 'mAppConnectForm', 'mAppConnectGo', 'mAppConnectCancel'].forEach((id) => {
107
+ const e = new El(id.endsWith('Open') || id.endsWith('Go') || id.endsWith('Cancel') ? 'button' : 'div', id);
108
+ if (id === 'mAppConnect' || id === 'mAppConnectForm') e.hidden = true;
109
+ card.appendChild(e);
110
+ });
111
+ roots.push(card);
112
+ const byId = (id) => { for (const r of roots) { const e = r.all().find((x) => x.id === id); if (e) return e; } return null; };
113
+ const document = { getElementById: byId, createElement: (t) => new El(t), createTextNode: (t) => new Text(t) };
114
+ const calls = [], watches = [];
115
+ const fetch = async (url, init) => {
116
+ const body = init && init.body ? JSON.parse(init.body) : null;
117
+ calls.push({ url, method: (init && init.method) || 'GET', body });
118
+ const k = url + (body && body.owner_id && url === LOOKUP ? '#' + body.owner_id : '');
119
+ const a = answers[k] !== undefined ? answers[k] : answers[url];
120
+ if (a === undefined) return { ok: false, status: 404, json: async () => ({ error: { message: 'not found' } }) };
121
+ if (a.status) return { ok: false, status: a.status, json: async () => ({ error: { message: a.message } }) };
122
+ return { ok: true, status: 200, json: async () => a };
123
+ };
124
+ // the status watch lives in the task-1004353 block; record it, and let a test drive its offer
125
+ const watchApp = (id, box, alive, everyMs, cardEl, offer) => { watches.push({ id, box, alive, everyMs, card: cardEl, offer }); return () => {}; };
126
+ const ctx = { document, fetch, watchApp, API: '/api/bongos', manageId: 7, JSON, String, Number, Object, encodeURIComponent };
127
+ ctx.$ = byId;
128
+ vm.createContext(ctx);
129
+ vm.runInContext(APPEL + '\n' + CONNECT + '\n' + PAGE + '\nthis.api = { loadAppStatus, offerAppConnect };', ctx);
130
+ const q = (id) => byId('m-' + id);
131
+ // What the watch does with an answer: show the card when the status view or the offer has something.
132
+ const paint = (data) => { const w = watches[watches.length - 1]; const offered = w.offer(data); card.hidden = !(data && data.app) && !offered; return offered; };
133
+ return { ...ctx.api, ctx, card, byId, q, calls, watches, paint, wizard: roots[0],
134
+ open: () => byId('mAppConnectOpen').fire('click'),
135
+ typeKey: (v) => { q('f-renderKey').value = v; q('f-renderKey').fire('input'); },
136
+ check: async () => { q('renderCheck').fire('click'); await flush(); },
137
+ pickWorkspace: async (i) => { q('renderWsList').fire('change', { target: { getAttribute: () => String(i) } }); await flush(); },
138
+ pickNew: () => q('renderSvcList').fire('change', { target: { name: 'm-renderSvc', value: 'new', disabled: false } }),
139
+ go: async () => { byId('mAppConnectGo').fire('click'); await flush(); },
140
+ };
141
+ }
142
+ const NONE = { ok: true, app: null, env_vars: [], values_held: false };
143
+ const ANSWERS = { [LOOKUP]: { workspaces: [{ id: TEA_A, name: 'Mine', type: 'team' }] }, [LOOKUP + '#' + TEA_A]: SERVICES };
144
+
145
+ async function readyToSend(w) {
146
+ w.loadAppStatus(7, INST); w.paint(NONE); w.open();
147
+ w.typeKey(KEY); await w.check(); await w.pickWorkspace(0); w.pickNew();
148
+ w.q('f-renderStart').value = 'npm start';
149
+ w.q('renderNew').fire('input', { target: w.q('f-renderStart') });
150
+ }
151
+
152
+ // ── the markup and the one-flow rule ────────────────────────────────────────
153
+
154
+ test('the card carries Connect Render, and the page builds nodes, never an HTML string', () => {
155
+ assert.ok(CARD, '#mAppCard exists');
156
+ assert.match(CARD, /<div id="mApp"><\/div>\n\s*<div class="appConnect" id="mAppConnect" hidden>/, 'under the status, hidden until offered');
157
+ assert.match(CARD, /<button type="button" class="btn small" id="mAppConnectOpen">Connect Render<\/button>/);
158
+ assert.match(CARD, /We keep hosting this project’s Builders' hall either way/, 'it says the hall stays with us (ADR 0345 D1)');
159
+ for (const [name, src] of [['the page region', PAGE], ['the shared flow', CONNECT]]) {
160
+ assert.doesNotMatch(src, /innerHTML|insertAdjacentHTML|outerHTML|document\.write/, `${name} builds nodes`);
161
+ }
162
+ });
163
+
164
+ test('the wizard and the page run the same flow — renderConnect() is called from both, and defined once', () => {
165
+ assert.equal((HUB.match(/function renderConnect\(/g) || []).length, 1);
166
+ assert.match(HUB, /var rc = renderConnect\(\$, state, \{/, 'the wizard');
167
+ assert.match(PAGE, /appConnect = renderConnect\(appConnectQ, appConnectSt, \{/, 'the project page');
168
+ assert.match(PAGE, /\$\('renderSetup'\)\.cloneNode\(true\)/, 'the page borrows the wizard\'s markup, not a copy of it');
169
+ assert.equal((HUB.match(/function renderLookup\(|function paintWorkspaces\(\) \{\n var list = renderWorkspaces/g) || []).length, 0, 'the wizard\'s old private copy is gone');
170
+ });
171
+
172
+ test('the added styles keep links on the link ink', () => {
173
+ const css = (HUB.match(/\/\* the same step on a project's page \(task 1004184\)[\s\S]*?\n(?!\.appConnect)/) || [''])[0];
174
+ assert.ok(css.includes('.appConnect'), 'the block is found');
175
+ assert.doesNotMatch(css, /--accent-deep/);
176
+ assert.match(HUB, /\.renderWhy a\{[^}]*color:var\(--link-ink\)/, 'the key-help link the page borrows is on --link-ink');
177
+ });
178
+
179
+ // ── when it is offered ──────────────────────────────────────────────────────
180
+
181
+ test('offered on the owner\'s own "no platform" answer, for a project the server would take', () => {
182
+ const w = load();
183
+ w.loadAppStatus(7, INST);
184
+ assert.equal(w.watches.length, 1);
185
+ assert.equal(w.watches[0].card, w.byId('mAppCard'), 'the watch is handed the card');
186
+ assert.equal(w.paint(NONE), true);
187
+ assert.equal(w.byId('mAppConnect').hidden, false);
188
+ assert.equal(w.card.hidden, false);
189
+ assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form waits for the owner to open it');
190
+ assert.equal(w.calls.length, 0, 'nothing is asked of Render until then');
191
+ });
192
+
193
+ test('not offered — and the card stays hidden — when the project could not take it or the answer is not the owner\'s', () => {
194
+ for (const [why, inst, data] of [
195
+ ['someone else\'s project: the owner-only read answered nothing', INST, null],
196
+ ['a project already on Render', INST, { ok: true, app: { platform: 'render', state: 'live', stage: 'live' }, env_vars: [] }],
197
+ ['a removed project', { ...INST, status: 'torn_down' }, NONE],
198
+ ['a project being removed', { ...INST, status: 'tearing_down' }, NONE],
199
+ ['no repository to build', { ...INST, target_ref: null }, NONE],
200
+ ]) {
201
+ const w = load();
202
+ w.loadAppStatus(7, inst);
203
+ assert.equal(w.paint(data), false, why);
204
+ assert.equal(w.byId('mAppConnect').hidden, true, why);
205
+ if (!(data && data.app)) assert.equal(w.card.hidden, true, why + ': the card hides');
206
+ }
207
+ });
208
+
209
+ // ── the flow ────────────────────────────────────────────────────────────────
210
+
211
+ test('opening it borrows the wizard\'s step under prefixed ids, and leaves the wizard\'s own alone', () => {
212
+ const w = load();
213
+ w.loadAppStatus(7, INST); w.paint(NONE);
214
+ w.open();
215
+ const copy = w.byId('m-renderSetup');
216
+ assert.ok(copy, 'the step is cloned in');
217
+ assert.equal(w.byId('mAppConnectForm').firstChild, copy, 'above the Connect / Cancel buttons');
218
+ assert.equal(copy.hidden, false);
219
+ assert.equal(w.byId('mAppConnectForm').hidden, false);
220
+ assert.equal(w.byId('mAppConnectOpen').hidden, true);
221
+ assert.equal(w.q('f-renderKey').focused, true, 'focus lands on the key');
222
+ assert.equal(w.q('f-renderKey').getAttribute('aria-describedby'), 'm-h-renderKey', 'its description points at its own hint');
223
+ assert.ok(w.wizard.all().every((e) => !e.id.startsWith('m-')), 'the wizard keeps its ids');
224
+ w.byId('mAppConnectCancel').fire('click'); w.open();
225
+ assert.equal(w.byId('mAppConnectForm').children.filter((c) => c.id === 'm-renderSetup').length, 1, 'cloned once, however often it is opened');
226
+ });
227
+
228
+ test('the same checks as the wizard, with the page\'s own words for a missing key', async () => {
229
+ const w = load({ answers: ANSWERS });
230
+ w.loadAppStatus(7, INST); w.paint(NONE); w.open();
231
+ await w.go();
232
+ assert.match(w.q('h-render').textContent, /^Check your Render key first\.$/, 'no "Decide later" here — there is no such choice on this card');
233
+ w.typeKey('ghp_notrender'); await w.check();
234
+ assert.match(w.q('h-renderKey').textContent, /starts with “rnd_”/);
235
+ assert.equal(w.calls.length, 0, 'a malformed key never leaves the page');
236
+ w.typeKey(KEY); await w.check(); await w.pickWorkspace(0);
237
+ const list = w.q('renderSvcList').html();
238
+ assert.match(list, /name="m-renderSvc" value="new"/, 'its own radio group, so it never joins the wizard\'s');
239
+ assert.match(list, /Render builds it from <code>example-owner\/shop<\/code>/, 'the project\'s own repository');
240
+ assert.match(list, /data-i="0">[\s\S]*Link shop/, 'its own app can be linked');
241
+ await w.go();
242
+ assert.match(w.q('h-render').textContent, /new app or link/);
243
+ assert.equal(w.calls.filter((c) => c.url === RENDER).length, 0);
244
+ });
245
+
246
+ test('Connect sends the key once, to this project, forgets it, and the card shows the app\'s progress', async () => {
247
+ const w = load({ answers: { ...ANSWERS, [RENDER]: { ok: true, queued: true } } });
248
+ await readyToSend(w);
249
+ await w.go();
250
+ const sent = w.calls.filter((c) => c.url === RENDER);
251
+ assert.equal(sent.length, 1);
252
+ assert.equal(sent[0].method, 'POST');
253
+ assert.deepEqual(sent[0].body, { key: KEY, owner_id: TEA_A, mode: 'greenfield', runtime: 'node', start_command: 'npm start', web_plan: 'starter', database: false });
254
+ for (const c of w.calls) assert.ok(!c.url.includes(KEY), 'no URL carries the key');
255
+ assert.equal(w.q('f-renderKey').value, '', 'the field is emptied');
256
+ assert.equal(w.byId('mAppConnectForm').hidden, true, 'the form closes');
257
+ assert.equal(w.watches.length, 2, 'the status is read again');
258
+ assert.equal(String(w.watches[1].id), '7');
259
+ assert.equal(w.paint({ ok: true, app: { platform: 'render', state: 'provisioning', stage: 'creating', waiting: true }, env_vars: [] }), false, 'and once the app has a platform, the offer steps aside');
260
+ assert.equal(w.byId('mAppConnect').hidden, true);
261
+ assert.equal(w.card.hidden, false, 'for the progress panel');
262
+ await w.go();
263
+ assert.equal(w.calls.filter((c) => c.url === RENDER).length, 1, 'a second press has no key to send');
264
+ });
265
+
266
+ test('a refusal says so in the server\'s words, and the key is gone all the same', async () => {
267
+ const w = load({ answers: { ...ANSWERS, [RENDER]: { status: 409, message: 'A \'provision\' run is running for this project, so your key was not kept. Try again once it has finished.' } } });
268
+ await readyToSend(w);
269
+ await w.go();
270
+ assert.match(w.q('h-render').textContent, /A 'provision' run is running for this project/);
271
+ assert.match(w.q('h-render').className, /err/);
272
+ assert.equal(w.q('f-renderKey').value, '');
273
+ assert.equal(w.byId('mAppConnectGo').disabled, false, 'the owner can try again');
274
+ assert.equal(w.watches.length, 1, 'no progress to watch');
275
+ await w.go();
276
+ assert.match(w.q('h-render').textContent, /Check your Render key first/, 'a retry starts with the key');
277
+ });
278
+
279
+ test('Cancel forgets the key; a reload of the same project keeps the open form; another project forgets every answer', async () => {
280
+ const w = load({ answers: ANSWERS });
281
+ w.loadAppStatus(7, INST); w.paint(NONE); w.open();
282
+ w.typeKey(KEY); await w.check(); await w.pickWorkspace(0);
283
+ w.loadAppStatus(7, INST); w.paint(NONE);
284
+ assert.equal(w.byId('mAppConnectForm').hidden, false, 'the page\'s reload of this project leaves the form open');
285
+ assert.equal(w.q('f-renderKey').value, KEY, 'with what the owner typed');
286
+ w.byId('mAppConnectCancel').fire('click');
287
+ assert.equal(w.q('f-renderKey').value, '');
288
+ assert.equal(w.byId('mAppConnectForm').hidden, true);
289
+ assert.equal(w.byId('mAppConnectOpen').hidden, false);
290
+ w.ctx.manageId = 8;
291
+ w.loadAppStatus(8, { ...INST, id: 8, target_ref: 'example-owner/other' });
292
+ w.paint(NONE); w.open();
293
+ assert.equal(w.q('renderWs').hidden, true, 'nothing Render said about project 7 is shown for project 8');
294
+ assert.match(w.q('h-renderKey').textContent, /^\s*$/, 'and it is not asked to re-enter a key it never gave');
295
+ });
@@ -9,8 +9,10 @@
9
9
  // on a mismatched workspace, on a Render error and on a decrypt failure
10
10
  // • the ownerId guard refuses a mismatched response, and the run stops there — nothing
11
11
  // is created after it
12
- // • ADOPT makes no write call to Render at all; GREENFIELD creates the database, then
13
- // the service, sets the SVC_URL-dependent env vars, then deploys — in that order
12
+ // • ADOPT makes no write call to Render at all, and links only a service that builds
13
+ // THIS project's repository; GREENFIELD creates the database only when asked for, then
14
+ // the app's service — built from the project's own repository the way its owner said,
15
+ // never the Bongos core (task 1004350, ADR 0345)
14
16
  // • the plan is read from the project's row, not a constant
15
17
  // • a failure is TERMINAL: the runner retires it instead of re-queuing it, because the
16
18
  // key is gone and the owner retries by entering it again
@@ -47,7 +49,7 @@ function fakeRender(over = {}) {
47
49
  'GET /postgres/dpg-new/connection-info': { internalConnectionString: 'postgres://internal' },
48
50
  'POST /services': { deployId: 'dep-1', service: { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } } },
49
51
  'GET /services/srv-new': { id: 'srv-new', ownerId: OURS, serviceDetails: { url: SVC_URL } },
50
- 'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, serviceDetails: { url: 'https://old.onrender.com' } },
52
+ 'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/Example-Owner/demo', serviceDetails: { url: 'https://old.onrender.com' } },
51
53
  'POST /services/srv-new/deploys': { id: 'dep-2' },
52
54
  ...over,
53
55
  };
@@ -81,7 +83,7 @@ function harness({ host = {}, intent = {}, render = {}, decryptThrows = false }
81
83
  renderApiFactory: ({ token, ownerId }) => new RenderApi({ token, ownerId, dryRun: false, fetchImpl: r.fetchImpl }),
82
84
  };
83
85
  const inst = { id: 18, slug: 'demo', owner_builder_id: 7, target_ref: 'example-owner/demo', status: 'active',
84
- app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, ...host } };
86
+ app_host: { platform: 'render', mode: 'greenfield', ownerId: OURS, runtime: 'node', buildCommand: 'npm ci', startCommand: 'npm start', database: true, ...host } };
85
87
  const it = { id: 44, action: 'render-standup', borrowed_key_enc: 'v1.enc', created_at: new Date().toISOString(), ...intent };
86
88
  const cleared = () => writes.filter((w) => /UPDATE provisioning_intents SET borrowed_key_enc = NULL WHERE id = \$1/.test(w.sql) && w.params[0] === 44).length;
87
89
  const appHost = () => writes.filter((w) => /SET app_host/.test(w.sql)).map((w) => JSON.parse(w.params[1]));
@@ -91,45 +93,62 @@ function harness({ host = {}, intent = {}, render = {}, decryptThrows = false }
91
93
 
92
94
  const writesTo = (calls) => calls.filter((c) => c.method !== 'GET');
93
95
 
94
- console.log('greenfield:');
95
- await ta('creates the database, then the service, sets the origins, then deploys — and clears the key', async () => {
96
+ console.log("greenfield — the owner's app:");
97
+ await ta("with a database: the database, then the app's service — and the key cleared", async () => {
96
98
  const h = harness();
97
99
  const out = await h.run();
98
100
  assert.equal(out.ok, true, out.error);
99
- assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path.replace(/env-vars\/.*/, 'env-vars/*')), [
100
- 'POST /postgres', 'POST /services',
101
- 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*', 'PUT /services/srv-new/env-vars/*',
102
- 'POST /services/srv-new/deploys',
103
- ]);
104
- const origins = h.r.calls.filter((c) => c.method === 'PUT').map((c) => [c.path.split('/').pop(), c.body.value]);
105
- assert.deepEqual(origins, [['BONGOS_PUBLIC_ORIGIN', SVC_URL], ['BONGOS_BUILDERS_ORIGIN', SVC_URL], ['BONGOS_STATUS_ORIGIN', SVC_URL], ['BONGOS_OAUTH_ORIGIN', SVC_URL]]);
101
+ assert.deepEqual(writesTo(h.r.calls).map((c) => c.method + ' ' + c.path), ['POST /postgres', 'POST /services'],
102
+ 'no env var PUTs and no extra deploy: Render deploys a new service on its own');
106
103
  assert.equal(h.cleared(), 1, 'the key column is cleared');
107
104
  const host = h.lastHost();
108
- assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, null], 'the row records what was created');
105
+ assert.deepEqual([host.postgresId, host.serviceId, host.state, host.serviceUrl, host.webPlan, host.error], ['dpg-new', 'srv-new', 'deploying', SVC_URL, 'starter', null], 'the row records the service, its URL and the plan');
109
106
  });
110
107
 
111
- await ta('the service is the pilot\'s: node runtime, explicit start + migrate, HOST, the database SVC_URL, a fresh secret, the owner seated', async () => {
108
+ await ta('without a database: no Postgres is created and the app gets no DATABASE_URL', async () => {
109
+ const h = harness({ host: { database: false } });
110
+ const out = await h.run();
111
+ assert.equal(out.ok, true, out.error);
112
+ assert.ok(!h.r.calls.some((c) => c.path.startsWith('/postgres')), 'nothing about a database is called');
113
+ const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
114
+ assert.deepEqual(svc.envVars, []);
115
+ assert.equal(h.lastHost().postgresId, undefined);
116
+ });
117
+
118
+ await ta("the service is the owner's app: their repository, runtime and commands — not the Bongos core", async () => {
112
119
  const h = harness();
113
120
  await h.run();
114
121
  const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
115
122
  assert.equal(svc.ownerId, OURS);
116
123
  assert.equal(svc.repo, 'https://github.com/example-owner/demo');
117
124
  assert.equal(svc.serviceDetails.runtime, 'node');
118
- assert.match(svc.serviceDetails.envSpecificDetails.startCommand, /platform-server\.js$/);
119
- assert.match(svc.serviceDetails.envSpecificDetails.preDeployCommand, /migrate\.sh$/);
120
- const env = Object.fromEntries(svc.envVars.map((v) => [v.key, v.value]));
121
- assert.equal(env.HOST, '0.0.0.0');
122
- assert.equal(env.DATABASE_URL, 'postgres://internal');
123
- assert.equal(env.PGDATABASE, 'postgres://internal');
124
- assert.match(env.BUILDER_SECRET_KEY, /^[0-9a-f]{64}$/);
125
- assert.equal(env.BONGOS_FIRST_ADMIN, 'owner-login');
126
- assert.ok(!('PORT' in env), 'Render injects PORT');
125
+ assert.deepEqual(svc.serviceDetails.envSpecificDetails, { buildCommand: 'npm ci', startCommand: 'npm start' });
126
+ const src = JSON.stringify(svc);
127
+ assert.doesNotMatch(src, /bongos|platform-server|migrate[.]sh|FIRST_ADMIN|BUILDER_SECRET_KEY|_ORIGIN/i, 'nothing of the hall rides along');
128
+ assert.deepEqual(svc.envVars, [{ key: 'DATABASE_URL', value: 'postgres://internal' }], 'the only value the platform sets');
127
129
  const stored = JSON.stringify(h.writes.map((w) => w.params));
128
- assert.ok(!stored.includes(env.BUILDER_SECRET_KEY) && !stored.includes('postgres://internal') && !stored.includes('rnd_borrowed'),
129
- 'no env var value and no key is ever written to our database');
130
+ assert.ok(!stored.includes('postgres://internal') && !stored.includes('rnd_borrowed'), 'no env var value and no key is ever written to our database');
131
+ });
132
+
133
+ await ta("docker builds the repository's own Dockerfile, with no commands", async () => {
134
+ const h = harness({ host: { runtime: 'docker', buildCommand: undefined, startCommand: undefined } });
135
+ const out = await h.run();
136
+ assert.equal(out.ok, true, out.error);
137
+ const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
138
+ assert.equal(svc.serviceDetails.runtime, 'docker');
139
+ assert.deepEqual(svc.serviceDetails.envSpecificDetails, {});
130
140
  });
131
141
 
132
- await ta('the plan is the project\'s own setting, not a constant', async () => {
142
+ await ta('a native runtime with no start command is refused before anything is created', async () => {
143
+ const h = harness({ host: { startCommand: undefined } });
144
+ const out = await h.run();
145
+ assert.equal(out.ok, false);
146
+ assert.match(out.error, /start command/);
147
+ assert.deepEqual(writesTo(h.r.calls), []);
148
+ assert.equal(h.cleared(), 1);
149
+ });
150
+
151
+ await ta("the plan is the project's own setting, not a constant", async () => {
133
152
  const h = harness({ host: { webPlan: 'standard', dbPlan: 'basic_1gb', region: 'frankfurt' } });
134
153
  await h.run();
135
154
  const pg = h.r.calls.find((c) => c.path === '/postgres').body;
@@ -147,7 +166,7 @@ await ta('a retry resumes onto what an earlier run created — no second databas
147
166
  const h = harness({ host: { postgresId: 'dpg-new', serviceId: 'srv-new' } });
148
167
  const out = await h.run();
149
168
  assert.equal(out.ok, true, out.error);
150
- assert.ok(!h.r.calls.some((c) => c.method === 'POST' && (c.path === '/postgres' || c.path === '/services')));
169
+ assert.deepEqual(writesTo(h.r.calls), []);
151
170
  });
152
171
 
153
172
  console.log('\nadopt:');
@@ -160,6 +179,15 @@ await ta('links an existing service and makes NO write call to Render', async ()
160
179
  assert.equal(h.lastHost().serviceUrl, 'https://old.onrender.com');
161
180
  assert.equal(h.cleared(), 1);
162
181
  });
182
+ await ta('a service that builds a DIFFERENT repository is refused — its URL would pose as this app', async () => {
183
+ const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: OURS, repo: 'https://github.com/example-owner/other-app' } } });
184
+ const out = await h.run();
185
+ assert.equal(out.ok, false);
186
+ assert.ok(out.error.includes('other-app, not example-owner/demo'), out.error);
187
+ assert.deepEqual(writesTo(h.r.calls), []);
188
+ assert.equal(h.lastHost().state, 'failed');
189
+ assert.equal(h.cleared(), 1);
190
+ });
163
191
  await ta('adopting a service from another workspace is refused, and the key still cleared', async () => {
164
192
  const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' }, render: { 'GET /services/srv-old': { id: 'srv-old', ownerId: THEIRS } } });
165
193
  const out = await h.run();
@@ -249,6 +277,71 @@ await ta('a failure is retired on its FIRST attempt, never re-queued', async ()
249
277
  assert.match(d.resolved[0].err, /cannot reach workspace/);
250
278
  });
251
279
 
280
+ console.log('\nthe wizard path — the app straight after the project, in the same run (task 1004352):');
281
+ const { afterProvision } = require('../scripts/gds/provision-render.js');
282
+ function afterHarness({ keyOnRow = 'v1.enc', host = {} } = {}) {
283
+ const h = harness({ host });
284
+ const base = h.deps.db.query;
285
+ h.deps.db = { query: async (sql, params) => (/SELECT borrowed_key_enc FROM provisioning_intents/.test(sql) ? { rows: [{ borrowed_key_enc: keyOnRow }] } : base(sql, params)) };
286
+ h.deps.provisioning = { getInstanceById: async () => ({ ...h.inst }) };
287
+ return h;
288
+ }
289
+ await ta('the project is up and a key rode the run → the app is set up, the key cleared, the run a success', async () => {
290
+ const h = afterHarness();
291
+ const out = await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true, port: 3100 });
292
+ assert.deepEqual(out, { ok: true, port: 3100 });
293
+ assert.ok(h.r.calls.some((c) => c.method === 'POST' && c.path === '/services'));
294
+ assert.equal(h.cleared(), 1);
295
+ });
296
+ await ta('the key is re-read from the row — one attached after the run was claimed is still spent', async () => {
297
+ const h = afterHarness();
298
+ await afterProvision(h.inst, h.deps, { id: 44, created_at: new Date().toISOString(), borrowed_key_enc: null }, { ok: true });
299
+ assert.ok(h.r.calls.length > 0);
300
+ assert.equal(h.cleared(), 1);
301
+ });
302
+ await ta('the app fails but the project is up → the run stays a success, with a note, and the key cleared', async () => {
303
+ const h = afterHarness();
304
+ h.r.calls.length = 0;
305
+ const hh = afterHarness({ host: { ownerId: 'tea-unreachable1' } });
306
+ const out = await afterProvision(hh.inst, hh.deps, { id: 44, created_at: new Date().toISOString() }, { ok: true });
307
+ assert.equal(out.ok, true);
308
+ assert.match(out.doneNote, /not set up on Render: this Render key cannot reach workspace/);
309
+ assert.equal(hh.lastHost().state, 'failed');
310
+ assert.equal(hh.cleared(), 1);
311
+ });
312
+ await ta('the project failed → Render is never called, the key cleared, the app marked failed', async () => {
313
+ const h = afterHarness();
314
+ const out = await afterProvision(h.inst, h.deps, { id: 44 }, { ok: false, error: 'DNS preflight failed' });
315
+ assert.deepEqual(out, { ok: false, error: 'DNS preflight failed' }, 'the provision result is passed through, so its retry rules are unchanged');
316
+ assert.equal(h.r.calls.length, 0);
317
+ assert.equal(h.cleared(), 1, 'a provision retry never carries a key into a later run');
318
+ assert.match(h.lastHost().error, /connect Render again/);
319
+ });
320
+ await ta('no key on the run → the provision result is untouched and Render is never called', async () => {
321
+ const h = afterHarness({ keyOnRow: null });
322
+ const r = { ok: true, already: true };
323
+ assert.equal(await afterProvision(h.inst, h.deps, { id: 44 }, r), r);
324
+ assert.equal(h.r.calls.length, 0);
325
+ assert.equal(h.cleared(), 0);
326
+ });
327
+ await ta('the runner reaches afterProvision on every provision run', () => {
328
+ const src = readFileSync(new URL('../scripts/gds/provision.js', import.meta.url), 'utf8');
329
+ assert.match(src, /intent\.action === 'provision'\) result = await require\('\.\/provision-render\.js'\)\.afterProvision\(inst, deps, intent, await provisionInstance\(inst, deps\)\)/);
330
+ });
331
+ await ta('a link needs a repository to check the service against (1004350 review)', async () => {
332
+ const h = harness({ host: { mode: 'adopt', serviceId: 'srv-old' } });
333
+ h.inst.target_ref = null;
334
+ const out = await h.run();
335
+ assert.equal(out.ok, false);
336
+ assert.equal(h.cleared(), 1);
337
+ });
338
+ await ta('an app with no build step sends no build command at all (1004350 review)', async () => {
339
+ const h = harness({ host: { buildCommand: undefined } });
340
+ await h.run();
341
+ const svc = h.r.calls.find((c) => c.method === 'POST' && c.path === '/services').body;
342
+ assert.deepEqual(svc.serviceDetails.envSpecificDetails, { startCommand: 'npm start' });
343
+ });
344
+
252
345
  console.log('\nteardown forgets, never deletes:');
253
346
  await ta('forgetRenderApp records the disconnect and calls nothing', async () => {
254
347
  const writes = [], logs = [];
@@ -567,5 +567,67 @@ await ta('cmdRunIntents (--apply): a dedicated refusal RETIRES at the cap with t
567
567
  assert.match(statusWrites[0].patch.error_note, /dedicated/);
568
568
  });
569
569
 
570
+ console.log('\nrenamedEnvCarry — the prelaunch password carried on the box (task 1004395):');
571
+
572
+ // A value that could never appear anywhere by accident, so "not in the text" means something.
573
+ const SECRET = 'zq7-Wall-Pass-$&-9x';
574
+
575
+ t('carries the old name to the new one when ONLY the old name is set, copying the right-hand side verbatim', () => {
576
+ const r = P.renamedEnvCarry(`PORT=3002\nSTEALTH_PASSWORD=${SECRET}\n`);
577
+ assert.deepEqual(r.vars, { PRELAUNCH_PASSWORD: SECRET });
578
+ assert.deepEqual(r.carried, ['STEALTH_PASSWORD -> PRELAUNCH_PASSWORD']);
579
+ assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD="quoted pw"\n').vars, { PRELAUNCH_PASSWORD: '"quoted pw"' }, 'quotes kept, so systemd reads the same value');
580
+ assert.deepEqual(P.renamedEnvCarry(`A=1\r\nSTEALTH_PASSWORD=${SECRET}\r\n`).vars, { PRELAUNCH_PASSWORD: SECRET }, 'a CRLF file carries no stray \\r');
581
+ });
582
+
583
+ t('no-op when the new name is already set — even to a different value', () => {
584
+ for (const body of [`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`, `STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=other\n`, `PRELAUNCH_PASSWORD=${SECRET}\n`]) {
585
+ assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, body);
586
+ }
587
+ });
588
+
589
+ t('no-op when the old name is absent, empty or commented out; an EMPTY new line is still filled', () => {
590
+ for (const body of ['PORT=3002\n', 'STEALTH_PASSWORD=\n', 'STEALTH_PASSWORD=""\n', `# STEALTH_PASSWORD=${SECRET}\n`, 'X_STEALTH_PASSWORD=abc\n', '', null]) {
591
+ assert.deepEqual(P.renamedEnvCarry(body), { vars: {}, carried: [] }, String(body));
592
+ }
593
+ assert.deepEqual(P.renamedEnvCarry(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=\n`).vars, { PRELAUNCH_PASSWORD: SECRET });
594
+ });
595
+
596
+ t('the last line of a key wins, as systemd reads an EnvironmentFile', () => {
597
+ assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=first\nSTEALTH_PASSWORD=second\n').vars, { PRELAUNCH_PASSWORD: 'second' });
598
+ assert.deepEqual(P.renamedEnvCarry('STEALTH_PASSWORD=pw\nPRELAUNCH_PASSWORD=set\nPRELAUNCH_PASSWORD=\n').vars, { PRELAUNCH_PASSWORD: 'pw' }, 'a later blank line unsets it');
599
+ });
600
+
601
+ await ta('the leg carries it in the SAME in-place patch: every other line untouched, the old name kept, the value in NO log, event or result', async () => {
602
+ const envBody = `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n`;
603
+ const { deps, writes, events, logs } = recorder({ apply: true, envBody });
604
+ const r = await P.settingsApplyInstance(activeStandalone, deps);
605
+ assert.equal(r.ok, true, JSON.stringify(r));
606
+ const out = writes[0].body;
607
+ assert.ok(out.endsWith(`STEALTH_PASSWORD=${SECRET}\nPRELAUNCH_PASSWORD=${SECRET}\n`), 'appended after the old line, which stays for task 1003830 to retire');
608
+ assert.equal(out.split('\n').length, envBody.split('\n').length + 1, 'exactly one line added');
609
+ assert.match(out, /_GITHUB_CLIENT_SECRET=0123456789abcdef0123456789abcdef01234567/, 'creds untouched');
610
+ assert.match(events[0].detail, /carried STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/, 'the event names the carry — how the owner verifies it without reading the file');
611
+ assert.ok(logs.some((l) => /carry the renamed env forward: STEALTH_PASSWORD -> PRELAUNCH_PASSWORD/.test(l)));
612
+ const said = [...logs, ...events.map((e) => e.detail), JSON.stringify(r)].join('\n');
613
+ assert.ok(!said.includes(SECRET) && !said.includes('zq7-Wall'), 'the password never leaves the file');
614
+ });
615
+
616
+ await ta('nothing to carry → the leg is byte-identical to before: no extra line, no carry wording', async () => {
617
+ const { deps, writes, events, logs } = recorder({ apply: true });
618
+ await P.settingsApplyInstance(activeStandalone, deps);
619
+ assert.equal(writes[0].body.split('\n').length, LIVE_ENV.split('\n').length);
620
+ assert.doesNotMatch(writes[0].body, /PRELAUNCH_PASSWORD/);
621
+ assert.doesNotMatch(events[0].detail, /carried/);
622
+ assert.ok(!logs.some((l) => /carry the renamed env/.test(l)));
623
+ });
624
+
625
+ await ta('a failed write reports the error without the value', async () => {
626
+ const { deps } = recorder({ apply: true, writeThrows: true, envBody: `${LIVE_ENV}STEALTH_PASSWORD=${SECRET}\n` });
627
+ const r = await P.settingsApplyInstance(activeStandalone, deps);
628
+ assert.equal(r.ok, false);
629
+ assert.ok(!r.error.includes(SECRET), r.error);
630
+ });
631
+
570
632
  console.log(`\nprovision_settings_apply: ${passed} passed, ${failed} failed`);
571
633
  process.exit(failed ? 1 : 0);