@bongos/core 1.20.38 → 1.20.40

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 +47 -37
  2. package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +1 -0
  3. package/docs/copy-inventory.md +1 -1
  4. package/docs/copy-registry.json +9 -9
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +251 -248
  7. package/modules/npm-release/preview/childlog.js +95 -0
  8. package/modules/npm-release/preview/commands.js +46 -5
  9. package/modules/npm-release/preview/divert.js +16 -6
  10. package/modules/npm-release/preview/env.js +18 -7
  11. package/modules/npm-release/preview/proxy.js +4 -1
  12. package/modules/npm-release/preview/runtime.js +43 -9
  13. package/modules/npm-release/preview/supervisor.js +170 -31
  14. package/modules/npm-release/public/work.css +3 -0
  15. package/modules/npm-release/public/work.js +6 -1
  16. package/modules/npm-release/routes/preview.js +7 -5
  17. package/modules/provisioning/render-standup.js +13 -1
  18. package/modules/public-landing/public/projects.html +22 -8
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/release-notes.json +16 -0
  22. package/scripts/gds/provision-render.js +13 -1
  23. package/scripts/gds/render-payload.js +6 -5
  24. package/src/module-api.js +1 -1
  25. package/tests/npm_release_preview_commands.mjs +1 -1
  26. package/tests/npm_release_preview_diagnosis.mjs +376 -0
  27. package/tests/npm_release_preview_divert.mjs +10 -0
  28. package/tests/npm_release_preview_proxy.mjs +26 -0
  29. package/tests/npm_release_preview_routes.mjs +15 -2
  30. package/tests/npm_release_preview_supervisor.mjs +4 -4
  31. package/tests/projects_hub_app_step.mjs +49 -0
  32. package/tests/projects_hub_render_connect.mjs +2 -1
  33. package/tests/provision_render.mjs +23 -2
@@ -1,29 +1,44 @@
1
1
  'use strict';
2
2
 
3
3
  // modules/npm-release/preview/supervisor.js — starts, watches and stops the ONE version
4
- // preview this hall may run (task 1004300, ADR 0349).
4
+ // preview this hall may run (task 1004300, ADR 0349; hardened by task 1004465).
5
5
  //
6
6
  // A preview is a second Bongos process, running an installed copy of an older core against
7
7
  // a COPY of the live database, reached through the live hall's own address (divert.js). This
8
8
  // file owns its lifecycle and nothing else: install → copy the database → migrate → launch →
9
- // wait until it answers. Every outward step goes through an injected function (run for a
10
- // command, spawn for the long-lived child, probe for the health check, sleep, now), which is
9
+ // wait until it can serve data. Every outward step goes through an injected function (run for
10
+ // a command, spawn for the long-lived child, probe for the health check, sleep, now), which is
11
11
  // what lets the whole lifecycle be tested without a process, a database or a network.
12
12
  //
13
13
  // ONE AT A TIME, STOPPED WHEN IDLE. A second preview is refused, not queued; a running one
14
14
  // that nothing has touched for IDLE_MS is stopped and its copy of the database dropped. That
15
15
  // bound is what keeps a preview from becoming a permanent second tenant of the droplet.
16
+ //
17
+ // THREE THINGS task 1004465 ADDED, each from the first live run:
18
+ // * the child's output is kept (redacted, capped) and shown when a start fails;
19
+ // * "running" means the child can serve DATA, not merely that it answers /healthz;
20
+ // * a stop waits for the child to exit and really drops the copy, or says it could not.
16
21
 
17
22
  const os = require('node:os');
18
23
  const fs = require('node:fs');
19
24
  const path = require('node:path');
20
- const { buildPreviewEnv } = require('./env');
25
+ const { buildPreviewEnv, PREVIEW_DB } = require('./env');
21
26
  const cmds = require('./commands');
27
+ const { createChildLog, redact, secretValues } = require('./childlog');
22
28
 
23
29
  const IDLE_MS = 20 * 60 * 1000;
24
30
  const DEFAULT_PORT = 3190;
25
31
  const HEALTH_TRIES = 40;
26
32
  const HEALTH_INTERVAL_MS = 1500;
33
+ // How long a stopping child gets to exit on SIGTERM before SIGKILL, and how long after that.
34
+ const KILL_WAIT_MS = 5000;
35
+ const KILL_FINAL_WAIT_MS = 2000;
36
+
37
+ // The ONLY files of the live instance's config/ copied into the preview's scratch instance:
38
+ // the identity and switch packs a hall reads at boot. An allow-list, like the environment
39
+ // (env.js): a deny-list forgets the next credential file someone adds, an allow-list cannot.
40
+ // Anything else in config/ (go-live.json, key files, whatever comes next) stays behind.
41
+ const CONFIG_COPY = /^(branding|modules|government|ideas|design-tokens)(\.neutral)?\.json$/;
27
42
 
28
43
  // How far behind the running version a preview may reach, counted in PUBLISHED versions.
29
44
  const WINDOW = 30;
@@ -52,43 +67,123 @@ function previewable(version, { published, running, window = WINDOW } = {}) {
52
67
  return { ok: true };
53
68
  }
54
69
 
70
+ /**
71
+ * Copy the live instance's host content the child needs to boot as an instance rather than
72
+ * as a bare install: config/ (branding, module switches, government) and migrations/instance/.
73
+ * A source that is absent is skipped; any other failure fails the start. Only the allow-listed
74
+ * pack files of config/ are copied (CONFIG_COPY).
75
+ */
76
+ function copyInstance({ fsImpl, from, to }) {
77
+ const parts = [
78
+ // The directory itself always passes the filter; files are copied only when allow-listed.
79
+ ['config', (src) => path.basename(src) === 'config' || CONFIG_COPY.test(path.basename(src))],
80
+ [path.join('migrations', 'instance'), () => true],
81
+ ];
82
+ for (const [rel, filter] of parts) {
83
+ const src = path.join(from, rel);
84
+ try {
85
+ if (!fsImpl.existsSync(src)) continue;
86
+ fsImpl.mkdirSync(path.dirname(path.join(to, rel)), { recursive: true });
87
+ fsImpl.cpSync(src, path.join(to, rel), { recursive: true, filter });
88
+ } catch (e) {
89
+ throw new Error(`could not copy the instance ${rel} into the preview: ${e && e.message}`);
90
+ }
91
+ }
92
+ }
93
+
55
94
  /**
56
95
  * @param {object} o
57
96
  * @param {Function} o.run (command) => Promise of { code, stderr } — runs one command to the end
58
97
  * @param {Function} o.spawn node:child_process spawn (the long-lived child)
59
- * @param {Function} o.probe (port) => Promise of boolean — does the preview answer its health check
98
+ * @param {Function} o.probe (port) => Promise of boolean | { ok, reason } — can the preview serve data
60
99
  * @param {string} o.liveDb the live database name (the source of the copy)
61
100
  * @param {string} o.cacheRoot where versions are installed
101
+ * @param {string} [o.instanceRoot] the live instance's root; its config/ and migrations/instance/
102
+ * are copied into the preview's scratch instance directory
62
103
  * @param {object} [o.env] the live environment (read by buildPreviewEnv only)
63
104
  */
64
105
  function createSupervisor({
65
- run, spawn, probe, liveDb, cacheRoot,
66
- env = process.env, port = DEFAULT_PORT,
106
+ run, spawn, probe, liveDb, cacheRoot, instanceRoot = null,
107
+ env = process.env, port = DEFAULT_PORT, killWaitMs = KILL_WAIT_MS,
67
108
  now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
68
109
  fsImpl = fs, tmpdir = os.tmpdir(), idleMs = IDLE_MS,
69
110
  log = { info() {}, warn() {}, error() {} },
111
+ copy = copyInstance,
70
112
  } = {}) {
113
+ // The one database a drop may ever name is the fixed copy. If the live database WERE that
114
+ // name, every stop would drop the live hall's data, so refuse to be built at all.
115
+ if (liveDb === PREVIEW_DB) throw new Error(`the live database cannot be named ${PREVIEW_DB}: that name is the preview's copy and is dropped on every stop`);
116
+
71
117
  let state = { state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null };
72
118
  let child = null;
73
119
  let workDir = null;
120
+ let childLog = null; // the running or just-stopped child's output
121
+ let keptTail = []; // its tail, kept after the scratch directory is gone
122
+ let secrets = secretValues(env);
123
+ let exitInfo = null; // { code } once the child has exited
124
+ let exited = Promise.resolve();
74
125
 
75
126
  const set = (patch) => { state = { ...state, ...patch }; };
76
- const status = () => ({ ...state, port, idle_ms: idleMs });
127
+ const status = () => ({ ...state, port, idle_ms: idleMs, log_tail: childLog ? childLog.tail() : keptTail });
77
128
 
78
129
  async function step(stage, command) {
79
130
  set({ stage });
80
131
  const r = await run(command);
81
132
  if (!r || r.code !== 0) {
82
- const tail = r && r.stderr ? `: ${String(r.stderr).trim().slice(-300)}` : '';
133
+ const tail = r && r.stderr ? `: ${redact(String(r.stderr).trim().slice(-300), secrets)}` : '';
83
134
  throw new Error(`${stage} failed (exit ${r ? r.code : '?'})${tail}`);
84
135
  }
85
136
  }
86
137
 
138
+ // Run a command only if it can touch nothing but the copy. Never throws for a failed command.
139
+ async function runDrop(command) {
140
+ if (!cmds.targetsOnlyPreviewDb(command)) throw new Error(`refusing a database command that does not name only ${PREVIEW_DB}`);
141
+ try { return (await run(command)) || { code: -1, stderr: 'no result' }; } catch (e) { return { code: -1, stderr: e && e.message }; }
142
+ }
143
+
144
+ /**
145
+ * Drop the copy, really. dropdb --force first (it ends stray connections itself); if that
146
+ * is refused, end the copy's connections — the copy's alone — and drop plainly. Returns
147
+ * null when the copy is gone, else a sentence saying it is not.
148
+ */
149
+ async function dropCopy() {
150
+ const first = await runDrop(cmds.dropDbCommand());
151
+ if (first.code === 0) return null;
152
+ await runDrop(cmds.terminateBackendsCommand());
153
+ const second = await runDrop(cmds.dropDbPlainCommand());
154
+ if (second.code === 0) return null;
155
+ const why = redact(String(second.stderr || first.stderr || '').trim().slice(-200), secrets);
156
+ return `the copy database ${PREVIEW_DB} could not be dropped (exit ${second.code})${why ? `: ${why}` : ''}`;
157
+ }
158
+
159
+ // Ask the child to stop and WAIT for it, then insist. The drop that follows needs the
160
+ // child's database connections gone; killing without waiting is what left the copy behind.
161
+ async function stopChild(c) {
162
+ if (exitInfo) return;
163
+ try { c.kill('SIGTERM'); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); }
164
+ await Promise.race([exited, sleep(killWaitMs)]);
165
+ if (exitInfo) return;
166
+ try { c.kill('SIGKILL'); } catch { /* already gone */ }
167
+ await Promise.race([exited, sleep(KILL_FINAL_WAIT_MS)]);
168
+ }
169
+
87
170
  // Everything a start or a stop leaves behind: the child, the copy, the scratch directory.
171
+ // Returns null when all of it is gone, else the sentence saying the copy is not.
88
172
  async function cleanup() {
89
- if (child) { try { child.kill(); } catch (e) { log.warn(`preview child would not stop: ${e && e.message}`); } child = null; }
90
- try { await run(cmds.dropDbCommand()); } catch (e) { log.warn(`preview copy could not be dropped: ${e && e.message}`); }
173
+ const c = child;
174
+ child = null;
175
+ if (c) await stopChild(c);
176
+ if (childLog) { keptTail = childLog.tail(); childLog.close(); childLog = null; }
177
+ const dropError = await dropCopy();
178
+ if (dropError) log.error(`preview: ${dropError}`);
91
179
  if (workDir) { try { fsImpl.rmSync(workDir, { recursive: true, force: true }); } catch { /* best effort */ } workDir = null; }
180
+ return dropError;
181
+ }
182
+
183
+ // A few of the child's last lines, to sit in the error message itself.
184
+ function withTail(message, lg) {
185
+ const lines = lg ? lg.tail(5) : [];
186
+ return lines.length ? `${message}. Its last output: ${lines.join(' | ')}`.slice(0, 900) : message;
92
187
  }
93
188
 
94
189
  async function launch(version) {
@@ -97,41 +192,78 @@ function createSupervisor({
97
192
  const home = path.join(workDir, 'home');
98
193
  fsImpl.mkdirSync(home, { recursive: true });
99
194
  const dumpFile = path.join(workDir, 'live.dump');
100
- const childEnv = buildPreviewEnv({ env, port, scratchHome: home });
195
+
196
+ // The child's working directory is a scratch INSTANCE: the live instance's config/ and
197
+ // migrations/instance/, copied. Run from the bare install directory it had no config/
198
+ // at all (no branding, no module switches) and was not shaped like a hall.
199
+ let instanceDir;
200
+ let prefix;
201
+ if (instanceRoot) {
202
+ instanceDir = path.join(workDir, 'instance');
203
+ fsImpl.mkdirSync(instanceDir, { recursive: true });
204
+ copy({ fsImpl, from: instanceRoot, to: instanceDir });
205
+ try { prefix = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'config', 'branding.json'), 'utf8')).envPrefix; } catch { /* no branding pack: the default prefix */ }
206
+ }
207
+ const childEnv = buildPreviewEnv({ env, port, scratchHome: home, instanceRoot: instanceDir, envPrefix: prefix });
208
+ secrets = secretValues(env, childEnv);
209
+ keptTail = [];
210
+ childLog = createChildLog({ secrets, fsImpl, file: path.join(workDir, 'preview.log') });
211
+ exitInfo = null;
101
212
 
102
213
  await step('install', { ...cmds.installCommand({ cacheRoot, version }), env: childEnv });
103
- await step('drop-old-copy', cmds.dropDbCommand());
214
+ set({ stage: 'drop-old-copy' });
215
+ const leftover = await dropCopy();
216
+ if (leftover) throw new Error(leftover);
104
217
  await step('create-copy', cmds.createDbCommand());
105
218
  await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
106
219
  await step('restore', cmds.restoreCommand({ dumpFile }));
107
- await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv }));
220
+ await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv, instanceDir }));
108
221
 
109
222
  set({ stage: 'launch' });
110
- child = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
111
- cwd: cmds.versionDir(cacheRoot, version), env: childEnv, stdio: 'ignore',
223
+ // The child's output is kept (redacted, capped): its errors used to go nowhere.
224
+ const mine = childLog;
225
+ const spawned = spawn(process.execPath, [cmds.entryPath(cacheRoot, version)], {
226
+ cwd: instanceDir || cmds.versionDir(cacheRoot, version), env: childEnv, stdio: ['ignore', 'pipe', 'pipe'],
112
227
  });
113
- child.on('exit', (code) => {
114
- // A child that dies on its own must not leave a copy behind or a page waiting on it.
115
- if (child && state.state === 'running') {
116
- log.warn(`preview of ${version} exited (${code})`);
117
- child = null;
118
- set({ state: 'failed', stage: 'exited', error: `the preview process exited (${code})` });
119
- cleanup();
120
- }
228
+ child = spawned;
229
+ exited = new Promise((resolve) => {
230
+ spawned.on('exit', (code) => {
231
+ exitInfo = { code };
232
+ resolve();
233
+ // A child that dies on its own must not leave a copy behind or a page waiting on it.
234
+ if (child === spawned && state.state === 'running') {
235
+ log.warn(`preview of ${version} exited (${code})`);
236
+ set({ state: 'failed', stage: 'exited', error: withTail(`the preview process exited (${code})`, mine) });
237
+ cleanup().then((err) => { if (err) set({ error: `${state.error}; ${err}` }); });
238
+ }
239
+ });
121
240
  });
241
+ for (const stream of [spawned.stdout, spawned.stderr]) {
242
+ if (stream && typeof stream.on === 'function') stream.on('data', (d) => mine.append(String(d)));
243
+ }
122
244
 
123
245
  set({ stage: 'health' });
246
+ // READY MEANS IT CAN SERVE DATA. The probe must see the database and the auth path work,
247
+ // not just the process answer (probeHealth in runtime.js); a preview that cannot is
248
+ // failed here, with why, rather than reported running.
124
249
  let up = false;
250
+ let why = 'it did not answer';
125
251
  for (let i = 0; i < HEALTH_TRIES && !up; i++) {
126
- up = await probe(port);
127
- if (!up) await sleep(HEALTH_INTERVAL_MS);
252
+ if (exitInfo) throw new Error(withTail(`the preview process exited (${exitInfo.code}) before it could serve data`, mine));
253
+ const r = await probe(port);
254
+ up = r === true || !!(r && r.ok);
255
+ if (!up) {
256
+ if (r && r.reason) why = r.reason;
257
+ await sleep(HEALTH_INTERVAL_MS);
258
+ }
128
259
  }
129
- if (!up) throw new Error('the preview did not answer in time');
260
+ if (!up) throw new Error(withTail(`the preview started but could not serve data (${why})`, mine));
130
261
  set({ state: 'running', stage: null, startedAt: now(), lastTouch: now() });
131
262
  } catch (e) {
132
263
  log.error(`preview of ${version} failed: ${e && e.message}`);
133
- set({ state: 'failed', error: e && e.message ? e.message : String(e) });
134
- await cleanup();
264
+ set({ state: 'failed', error: redact(e && e.message ? e.message : String(e), secrets) });
265
+ const dropError = await cleanup();
266
+ if (dropError) set({ error: `${state.error}; ${dropError}` });
135
267
  }
136
268
  }
137
269
 
@@ -146,13 +278,20 @@ function createSupervisor({
146
278
  if (state.version === version) return { ok: true, already: true, status: status(), done: Promise.resolve() };
147
279
  return { ok: false, code: 'preview_busy', status: status() };
148
280
  }
281
+ keptTail = [];
149
282
  set({ state: 'starting', version, stage: 'queued', error: null, startedAt: null, lastTouch: now() });
150
283
  return { ok: true, status: status(), done: launch(version) };
151
284
  }
152
285
 
153
286
  /** Stop the preview and drop its copy. Safe to call at any point. */
154
287
  async function stop() {
155
- await cleanup();
288
+ const dropError = await cleanup();
289
+ // A copy that is still there is NOT idle: say so, where the deploy page will show it.
290
+ if (dropError) {
291
+ set({ state: 'failed', stage: 'drop', error: dropError, startedAt: null, lastTouch: null });
292
+ return status();
293
+ }
294
+ keptTail = [];
156
295
  set({ state: 'idle', version: null, stage: null, error: null, startedAt: null, lastTouch: null });
157
296
  return status();
158
297
  }
@@ -172,4 +311,4 @@ function createSupervisor({
172
311
  return { start, stop, status, touch, reapIdle };
173
312
  }
174
313
 
175
- module.exports = { createSupervisor, previewable, IDLE_MS, DEFAULT_PORT, WINDOW };
314
+ module.exports = { createSupervisor, previewable, copyInstance, IDLE_MS, DEFAULT_PORT, WINDOW };
@@ -6,3 +6,6 @@
6
6
  /* A stage's sentence sits under its head (work.js says why) and needs air before the first
7
7
  row, which drops its own top rule and padding in oversight.css. */
8
8
  #nr-work .ov-group > .ov-row__sub { margin: 8px 0 6px; }
9
+
10
+ /* the preview process output shown under a failed preview (task 1004465) */
11
+ .ov-log { max-height: 16rem; overflow: auto; margin: 0.5rem 0 0; padding: 0.5rem 0.75rem; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; word-break: break-word; background: rgba(127, 127, 127, 0.12); border-radius: 6px; }
@@ -192,7 +192,12 @@
192
192
  return `<div class="ov-note"><p><strong>Preparing a preview of ${v}</strong> — ${escapeHtml(PREVIEW_STEP[preview.stage] || 'working')}. This can take a few minutes.</p></div>`;
193
193
  }
194
194
  if (preview.state === 'failed') {
195
- return `<div class="ov-note ov-note--warn"><p><strong>The preview of ${v} did not start.</strong> ${escapeHtml(preview.error || '')}</p><button type="button" class="ov-toggle" data-preview-stop>Clear</button></div>`;
195
+ // What the preview process itself printed (secrets already removed on the server), so a
196
+ // failed preview can be diagnosed here without a shell on the box (task 1004465).
197
+ const lines = Array.isArray(preview.log_tail) ? preview.log_tail : [];
198
+ const output = lines.length ? `<details open><summary>What the preview printed</summary><pre class="ov-log">${escapeHtml(lines.join('\n'))}</pre></details>` : '';
199
+ const headline = preview.stage === 'drop' ? `The copy of the data for ${v} could not be removed.` : `The preview of ${v} did not start.`;
200
+ return `<div class="ov-note ov-note--warn"><p><strong>${headline}</strong> ${escapeHtml(preview.error || '')}</p>${output}<button type="button" class="ov-toggle" data-preview-stop>${preview.stage === 'drop' ? 'Try again' : 'Clear'}</button></div>`;
196
201
  }
197
202
  return `<div class="ov-note"><p><strong>Preview of ${v} is running</strong> on a copy of this hall's data. Nothing done there reaches the live hall. It stops by itself after ${Math.round((preview.idle_ms || 0) / 60000)} minutes unused.</p>
198
203
  <a class="ov-toggle" href="${API}/npm-release/preview/enter?v=${encodeURIComponent(preview.version)}">Open the preview</a>
@@ -20,8 +20,8 @@
20
20
 
21
21
  const express = require('express');
22
22
  const api = require('../../../src/module-api');
23
- const { createDivert, clearCookie } = require('../preview/divert');
24
- const { createProxy, COOKIE_NAME } = require('../preview/proxy');
23
+ const { createDivert, clearCookie, setCookie } = require('../preview/divert');
24
+ const { createProxy } = require('../preview/proxy');
25
25
  const { previewable } = require('../preview/supervisor');
26
26
  const { VERSION_RE, PACKAGE } = require('../preview/commands');
27
27
 
@@ -55,6 +55,7 @@ module.exports = function buildNpmReleasePreviewRouter({
55
55
  const router = express.Router();
56
56
  const sup = supervisor || lazySupervisor(() => require('../preview/runtime').createRuntime({
57
57
  liveDb: process.env.PGDATABASE || api.instanceDbName(),
58
+ instanceRoot: api.resolveInstanceRoot(),
58
59
  log,
59
60
  }));
60
61
  const mayPreview = api.requirePermission('core.pin.move');
@@ -118,14 +119,15 @@ module.exports = function buildNpmReleasePreviewRouter({
118
119
  if (!VERSION_RE.test(v) || st.state !== 'running' || st.version !== v) {
119
120
  return failure(res, 409, 'preview_not_running', 'That preview is not running.');
120
121
  }
121
- const secure = req.secure ? '; Secure' : '';
122
- res.append('Set-Cookie', `${COOKIE_NAME}=${v}; Path=/; HttpOnly; SameSite=Lax${secure}`);
122
+ res.append('Set-Cookie', setCookie(v, req));
123
+ res.set('Cache-Control', 'no-store');
123
124
  sup.touch();
124
125
  return res.redirect(302, '/');
125
126
  });
126
127
 
127
128
  router.get('/npm-release/preview/exit', api.requireBuilder, mayPreview, (req, res) => {
128
- res.append('Set-Cookie', clearCookie());
129
+ res.append('Set-Cookie', clearCookie(req));
130
+ res.set('Cache-Control', 'no-store');
129
131
  res.redirect(302, '/deploy');
130
132
  });
131
133
 
@@ -31,6 +31,18 @@ const MODES = ['adopt', 'greenfield'];
31
31
  // How the owner's app is built (task 1004350): a native runtime with their commands, or
32
32
  // `docker`, which builds the repository's own Dockerfile.
33
33
  const RUNTIMES = ['node', 'python', 'ruby', 'go', 'rust', 'elixir', 'docker'];
34
+ // Render refuses a native web service with no build command (measured live 2026-09-30,
35
+ // task 1004464), so every native runtime has one: Render's own default for it. The runner
36
+ // sends it when the owner gave none, and the wizard prefills it (projects.html keeps a
37
+ // copy, pinned equal by tests/projects_hub_app_step.mjs).
38
+ const BUILD_DEFAULTS = Object.freeze({
39
+ node: 'npm install',
40
+ python: 'pip install -r requirements.txt',
41
+ ruby: 'bundle install',
42
+ go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
43
+ rust: 'cargo build --release',
44
+ elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile',
45
+ });
34
46
 
35
47
  // Render API keys start `rnd_`; workspaces are `tea-…` (a team) or `usr-…` (a personal
36
48
  // one); services `srv-…`. Shape checks only — whether the key works, and whether it can
@@ -134,4 +146,4 @@ async function attachToProvision(db, instanceId, { keyEnc, appHost }) {
134
146
  return rows[0] || null;
135
147
  }
136
148
 
137
- module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, badRequest, appHostFrom, enqueueRenderStandup };
149
+ module.exports = { attachToProvision, KEY_RE, OWNER_RE, WEB_PLANS, DB_PLANS, REGIONS, MODES, RUNTIMES, BUILD_DEFAULTS, badRequest, appHostFrom, enqueueRenderStandup };
@@ -1433,7 +1433,7 @@ summary{min-height:24px;padding:3px 0;}
1433
1433
  <option value="docker">Docker — use my repository’s Dockerfile</option>
1434
1434
  </select>
1435
1435
  </label>
1436
- <label class="wizField" id="renderBuildWrap"><span>Build command <small>— leave empty if there is none</small></span>
1436
+ <label class="wizField" id="renderBuildWrap"><span>Build command <small>— Render needs one; the usual one is filled in</small></span>
1437
1437
  <input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
1438
1438
  </label>
1439
1439
  <label class="wizField" id="renderStartWrap"><span>Start command</span>
@@ -1808,6 +1808,18 @@ summary{min-height:24px;padding:3px 0;}
1808
1808
  function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
1809
1809
  var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
1810
1810
  var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
1811
+ /* Render refuses a native app with no build command (task 1004464), so the box starts
1812
+ with Render's own default for the runtime — a real value the owner can change, and
1813
+ one that follows the runtime until they do. A copy of render-standup.js's
1814
+ BUILD_DEFAULTS (this page cannot require it), pinned equal by a test. */
1815
+ var RENDER_BUILD_DEFAULTS = {
1816
+ node: 'npm install',
1817
+ python: 'pip install -r requirements.txt',
1818
+ ruby: 'bundle install',
1819
+ go: 'go build -tags netgo -ldflags \'-s -w\' -o app',
1820
+ rust: 'cargo build --release',
1821
+ elixir: 'mix deps.get --only prod && MIX_ENV=prod mix compile'
1822
+ };
1811
1823
  var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
1812
1824
  function planLabel(list, v) {
1813
1825
  var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
@@ -1894,6 +1906,8 @@ summary{min-height:24px;padding:3px 0;}
1894
1906
  q('f-renderRuntime').value = st.renderRuntime || 'node';
1895
1907
  var docker = q('f-renderRuntime').value === 'docker';
1896
1908
  q('renderBuildWrap').hidden = docker; q('renderStartWrap').hidden = docker;
1909
+ // the runtime's usual build command until the owner types their own (task 1004464)
1910
+ if (!st.renderBuildTouched) st.renderBuild = RENDER_BUILD_DEFAULTS[q('f-renderRuntime').value] || '';
1897
1911
  q('f-renderBuild').value = st.renderBuild || '';
1898
1912
  q('f-renderStart').value = st.renderStart || '';
1899
1913
  fillPlans(q('f-renderPlan'), RENDER_WEB_PLANS, st.renderPlan || 'starter');
@@ -1975,13 +1989,12 @@ summary{min-height:24px;padding:3px 0;}
1975
1989
  hooks.save(); syncNew();
1976
1990
  });
1977
1991
  q('renderNew').addEventListener('input', function (ev) {
1978
- if (ev.target.id === q('f-renderBuild').id) st.renderBuild = ev.target.value;
1979
- if (ev.target.id === q('f-renderStart').id) {
1980
- st.renderStart = ev.target.value;
1981
- // the "say how your app starts" ask is answered the moment there is a command —
1982
- // it used to stay on screen until the next press of Next
1983
- if (String(st.renderStart).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
1984
- }
1992
+ var typed = null;
1993
+ if (ev.target.id === q('f-renderBuild').id) { st.renderBuild = ev.target.value; st.renderBuildTouched = true; typed = st.renderBuild; }
1994
+ if (ev.target.id === q('f-renderStart').id) { st.renderStart = ev.target.value; typed = st.renderStart; }
1995
+ // a "say how your app starts / is built" ask is answered the moment that box has a
1996
+ // command — it used to stay on screen until the next press of Next
1997
+ if (typed != null && String(typed).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
1985
1998
  hooks.save();
1986
1999
  });
1987
2000
  function validate() {
@@ -1993,6 +2006,7 @@ summary{min-height:24px;padding:3px 0;}
1993
2006
  if (st.renderMode === 'adopt') {
1994
2007
  if (!services.some(function (s) { return s.id === st.renderServiceId && repoOf(s.repo) === repo().toLowerCase(); })) return bad('Choose the app to link, or create a new one.');
1995
2008
  } else if (st.renderMode === 'greenfield') {
2009
+ if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderBuild || '').trim()) return bad('Say how your app is built — Render needs a build command, for example “npm install”.');
1996
2010
  if ((st.renderRuntime || 'node') !== 'docker' && !String(st.renderStart || '').trim()) return bad('Say how your app starts — the command Render should run, for example “npm start”.');
1997
2011
  } else return bad('Choose whether to create a new app or link one you already run.');
1998
2012
  hint('h-render', '');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.38",
3
+ "version": "1.20.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.38",
9
+ "version": "1.20.40",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.38",
3
+ "version": "1.20.40",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8333,5 +8333,21 @@
8333
8333
  {
8334
8334
  "text": "Builders on any Cloud Bongos project can now send feedback about Bongos itself with one command, and it arrives with the Bongos team as a task waiting for approval."
8335
8335
  }
8336
+ ],
8337
+ "1.20.39": [
8338
+ {
8339
+ "id": "1004465",
8340
+ "text": "A version preview that cannot read its data now fails with the child's own output shown, and stopping it really removes its copy."
8341
+ },
8342
+ {
8343
+ "id": "1004464",
8344
+ "text": "Setting up an app on Render from the project wizard no longer fails when the owner leaves the build command empty. The box now comes filled in with Render's usual build command for the chosen language, which the owner can chan"
8345
+ }
8346
+ ],
8347
+ "1.20.40": [
8348
+ {
8349
+ "id": "1004466",
8350
+ "text": "Stop the version preview from overwriting the live hall's cookies, and make leaving a preview reliably clear it."
8351
+ }
8336
8352
  ]
8337
8353
  }
@@ -117,6 +117,18 @@ async function greenfield(api, inst, host, deps) {
117
117
  return { ok: true };
118
118
  }
119
119
 
120
+ // The owner reads this reason on the setup screen (app-status.js oneSentence). A refusal
121
+ // from Render's API used to arrive as its raw line — "Render POST /services failed: …" —
122
+ // so an answer from Render is re-said as a sentence with Render's own reason kept whole
123
+ // (task 1004464). Anything else is already the leg's own words.
124
+ function plainReason(e) {
125
+ const msg = e && e.message ? e.message : String(e);
126
+ const said = e && e.status != null && e.body && typeof e.body.message === 'string' ? e.body.message.trim() : '';
127
+ if (said) return `Render would not set up the app: ${said}`;
128
+ const m = /^Render (?:GET|POST|PATCH|PUT|DELETE) \S+ failed: ([\s\S]+)$/.exec(msg);
129
+ return m ? `Render would not set up the app: ${m[1].trim()}` : msg;
130
+ }
131
+
120
132
  async function renderStandupInstance(inst, deps, intent) {
121
133
  const { db, log } = deps;
122
134
  const secretBox = deps.secretBox || require('../../src/bongos/secret-box');
@@ -137,7 +149,7 @@ async function renderStandupInstance(inst, deps, intent) {
137
149
 
138
150
  return host.mode === 'adopt' ? await adopt(api, inst, host, deps) : await greenfield(api, inst, host, deps);
139
151
  } catch (e) {
140
- return refuse(e && e.message ? e.message : String(e));
152
+ return refuse(plainReason(e));
141
153
  } finally {
142
154
  if (intent && intent.id != null) await clearBorrowedKey(db, intent.id);
143
155
  }
@@ -21,7 +21,7 @@
21
21
  'use strict';
22
22
 
23
23
  // The vocabulary lives with the web tier's validation, so the two can never disagree.
24
- const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES } = require('../../modules/provisioning/render-standup.js');
24
+ const { WEB_PLANS, DB_PLANS, REGIONS, RUNTIMES, BUILD_DEFAULTS } = require('../../modules/provisioning/render-standup.js');
25
25
  const DEFAULTS = Object.freeze({ webPlan: 'starter', dbPlan: 'basic_256mb', region: 'virginia' });
26
26
 
27
27
  // A Render resource name from the project's slug. Render allows letters, digits and
@@ -47,14 +47,15 @@ function plansFor(appHost) {
47
47
  }
48
48
 
49
49
  // How the app is built and started, as the owner said. `docker` builds the repository's
50
- // own Dockerfile and needs no commands; every other runtime needs a start command, and a
51
- // build command if the app has a build step.
50
+ // own Dockerfile and needs no commands; every other runtime needs a start command AND a
51
+ // build command — Render refuses a native service without one (task 1004464), so a row
52
+ // saved with none (including every row from before that fix) gets the runtime's default.
52
53
  function buildFor(appHost) {
53
54
  const h = appHost || {};
54
55
  if (!RUNTIMES.includes(h.runtime)) throw new Error(`unknown Render runtime '${h.runtime || 'none'}'`);
55
56
  if (h.runtime === 'docker') return { runtime: 'docker' };
56
57
  if (!h.startCommand) throw new Error('the app needs a start command');
57
- return { runtime: h.runtime, buildCommand: h.buildCommand || null, startCommand: h.startCommand };
58
+ return { runtime: h.runtime, buildCommand: h.buildCommand || BUILD_DEFAULTS[h.runtime], startCommand: h.startCommand };
58
59
  }
59
60
 
60
61
  // POST /postgres. `plan` is never 'free' — see the header.
@@ -68,7 +69,7 @@ function renderPostgresPayload(inst, { ownerId, dbPlan, region }) {
68
69
  function renderServicePayload(inst, { ownerId, repoUrl, branch, webPlan, region, build, dbUrl }) {
69
70
  const envSpecificDetails = build.runtime === 'docker'
70
71
  ? {}
71
- : { ...(build.buildCommand ? { buildCommand: build.buildCommand } : {}), startCommand: build.startCommand };
72
+ : { buildCommand: build.buildCommand, startCommand: build.startCommand };
72
73
  return {
73
74
  type: 'web_service',
74
75
  name: resourceName(inst),
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.38'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -52,7 +52,7 @@ test('the copy is a custom-format dump of the live database, restored with no ow
52
52
 
53
53
  test('the copy is created empty and dropped if it is already there', () => {
54
54
  assert.deepEqual(c.createDbCommand(), { cmd: 'createdb', args: [PREVIEW_DB] });
55
- assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] });
55
+ assert.deepEqual(c.dropDbCommand(), { cmd: 'dropdb', args: ['--if-exists', '--force', PREVIEW_DB] });
56
56
  });
57
57
 
58
58
  test('the install is one version of the core into its own directory, production dependencies only', () => {