@bongos/core 1.19.1033 → 1.19.1035

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 (114) hide show
  1. package/.bongos-core.json +204 -99
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/clients/bongos-client/README.md +1 -1
  7. package/clients/bongos-client/bongos-client.global.js +6 -0
  8. package/clients/bongos-client/index.cjs +6 -0
  9. package/clients/bongos-client/index.d.ts +8 -0
  10. package/clients/bongos-client/index.mjs +6 -0
  11. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  12. package/docs/adr/README.md +1 -1
  13. package/docs/api/openapi.json +120 -4
  14. package/docs/api-reference.md +9 -2
  15. package/docs/architecture.md +3 -2
  16. package/docs/canonical-permissions.md +2 -2
  17. package/docs/copy-inventory.md +34 -30
  18. package/docs/copy-registry.json +69 -33
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/recipes/local-dev.md +45 -3
  21. package/docs/recipes/packaging-the-core.md +1 -1
  22. package/modules/autonomy/cadence.js +52 -0
  23. package/modules/autonomy/db.js +19 -0
  24. package/modules/autonomy/routes/autonomy.js +28 -0
  25. package/modules/government/catalog.js +2 -2
  26. package/modules/government/protected-surfaces.json +1 -0
  27. package/modules/grading/grade-engine.js +267 -0
  28. package/modules/grading/grader-subagent.js +4 -3
  29. package/modules/grading/grader-workers/index.js +67 -11
  30. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  31. package/modules/grading/grader.js +109 -88
  32. package/modules/hall-ui/public/deploy.js +35 -0
  33. package/modules/hall-ui/public/project-settings.css +7 -0
  34. package/modules/lifecycle/routes/tasks.js +19 -4
  35. package/modules/lifecycle/ship-assurance.js +17 -1
  36. package/modules/provisioning/control-plane-guard.js +14 -13
  37. package/modules/provisioning/core-upgrade.js +30 -17
  38. package/modules/provisioning/public-refusal.js +44 -0
  39. package/modules/provisioning/routes/disconnect.js +2 -1
  40. package/modules/provisioning/routes/env-manifest.js +5 -4
  41. package/modules/provisioning/routes/provisioning.js +16 -15
  42. package/modules/provisioning/routes/repo-private.js +2 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +80 -0
  46. package/scripts/gds/autobongos-run.js +10 -2
  47. package/scripts/gds/backfill-cost.js +20 -9
  48. package/scripts/gds/claim.js +2 -2
  49. package/scripts/gds/credential-presence.js +281 -0
  50. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  51. package/scripts/gds/dev-lib.js +212 -5
  52. package/scripts/gds/dev.js +102 -50
  53. package/scripts/gds/doctor.js +3 -2
  54. package/scripts/gds/grade-correlation-audit.js +280 -0
  55. package/scripts/gds/grade-replay.js +237 -0
  56. package/scripts/gds/hooks/pre-push.js +7 -3
  57. package/scripts/gds/main-audit.js +19 -10
  58. package/scripts/gds/memory-map.js +3 -1
  59. package/scripts/gds/onboarding-config.js +3 -2
  60. package/scripts/gds/plain-cards.js +4 -0
  61. package/scripts/gds/preflight.js +12 -4
  62. package/scripts/gds/provision-core-upgrade.js +155 -4
  63. package/scripts/gds/provision-repo-auth.js +15 -0
  64. package/scripts/gds/provision.js +1 -1
  65. package/scripts/gds/publish-credential-check.js +3 -0
  66. package/scripts/gds/push-main.js +14 -3
  67. package/scripts/gds/push-path-brief.js +3 -2
  68. package/scripts/gds/run-routine.js +18 -5
  69. package/scripts/gds/run-unit-tests.js +14 -3
  70. package/scripts/gds/session-cost.js +12 -3
  71. package/scripts/gds/ship-deploy-target.js +11 -6
  72. package/scripts/gds/ship-grade-steps.js +33 -0
  73. package/scripts/gds/ship-land.js +157 -11
  74. package/scripts/gds/ship-merge.js +3 -2
  75. package/scripts/gds/ship-preflight-steps.js +30 -8
  76. package/scripts/gds/ship.js +1 -1
  77. package/scripts/gds/upgrade.js +3 -3
  78. package/scripts/hall-preview/README.md +20 -3
  79. package/scripts/hall-preview/live-identity.js +1 -1
  80. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  81. package/scripts/hall-preview/scrub-identity.js +296 -0
  82. package/src/bongos/core-update.js +23 -1
  83. package/src/bongos/routes/core-update.js +27 -2
  84. package/src/bongos/routes/security.js +3 -3
  85. package/src/build-info.js +4 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/adr_0089_scope.mjs +70 -0
  88. package/tests/autobongos_cadence.mjs +74 -0
  89. package/tests/consumer_layout_boot.mjs +363 -31
  90. package/tests/core_upgrade_door.mjs +40 -6
  91. package/tests/core_upgrade_runner.mjs +240 -4
  92. package/tests/credential_presence.mjs +153 -0
  93. package/tests/deploy_audit_gate.mjs +5 -2
  94. package/tests/deploy_page_projects.mjs +63 -2
  95. package/tests/dev_lib.mjs +2 -1
  96. package/tests/dev_windows.mjs +324 -0
  97. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  98. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  99. package/tests/government_protected_surfaces.mjs +10 -0
  100. package/tests/grade_correlation_audit.mjs +159 -0
  101. package/tests/grade_engine.mjs +249 -0
  102. package/tests/grade_engine_replay.mjs +99 -0
  103. package/tests/grader_prompt_window_bound.mjs +132 -0
  104. package/tests/grader_unbacked_score.mjs +194 -0
  105. package/tests/hall_preview_scrub.mjs +214 -0
  106. package/tests/integration_parked_ratchet.mjs +24 -1
  107. package/tests/provisioning_public_refusal.mjs +195 -0
  108. package/tests/publish_status_branch.mjs +128 -0
  109. package/tests/routine_observability_api.mjs +195 -0
  110. package/tests/ship_assurance.mjs +86 -3
  111. package/tests/ship_land_red_pr.mjs +236 -0
  112. package/tests/ship_preflight.mjs +67 -1
  113. package/tests/start_env_guard.mjs +16 -0
  114. package/tests/upgrade.mjs +20 -1
@@ -0,0 +1,281 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/credential-presence.js — which credentials does this HOST hold? Names and
3
+ // presence only, never a value (task 1002880, from idea 1000630).
4
+ //
5
+ // Run ON THE HOST, as the user that can read the instance's env files (usually via sudo):
6
+ // node scripts/gds/credential-presence.js # reads /etc/<configDir>/*.env
7
+ // node scripts/gds/credential-presence.js --etc /etc/example --json
8
+ //
9
+ // WHY. The owner was asked to create GitHub credentials at least three times, and each
10
+ // time the credential already existed — nothing could answer "what does this instance
11
+ // need, what is set, what is missing". docs/credentials.md is the written half (what each
12
+ // credential is for, where it lives, who may hold it); this is the one-command half, run
13
+ // where the values actually live. doctor.js cannot do it: it runs on a builder's laptop,
14
+ // which never sees the host's env files, and that is by design.
15
+ //
16
+ // THE ONE RULE: A VALUE NEVER LEAVES THE FILE. The report says set / empty / missing and
17
+ // the NAME of the variable that satisfied each row — never the value, never a prefix,
18
+ // never a length. tests/credential_presence.mjs asserts no fragment of any value reaches
19
+ // the output. If this is ever surfaced over HTTP it must be Archon-gated, and it still
20
+ // returns names only.
21
+ //
22
+ // Deeper than presence: `node scripts/gds/publish-credential-check.js` (task 1002733)
23
+ // probes whether the publish credential can actually read the Actions API. This file
24
+ // does not call GitHub — presence is the question it answers, offline.
25
+ //
26
+ // Exit: 0 = every REQUIRED row is satisfied · 1 = a required row is missing ·
27
+ // 2 = an env file this host should have could not be read (the answer is unknown).
28
+
29
+ 'use strict';
30
+
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+ const { resolveEnv, envPrefix, configDirName, configPath, LEGACY_ENV_PREFIXES } = require('../../src/instance-config');
34
+ const { arg, hasFlag, cliExit } = require('./cli-lib');
35
+
36
+ // ---- the inventory -------------------------------------------------------------------
37
+ //
38
+ // One row per credential this instance's HOST may hold. `names` are the env var(s) that
39
+ // must ALL be non-empty for the row to count as set; `prefixed` names resolve through the
40
+ // instance's own env prefix (and its legacy spellings) the way the server reads them.
41
+ // Rows sharing a `group` are alternatives: the group is satisfied when any member is.
42
+ // `file` is the env file the value lives in on a control-plane host:
43
+ // web.env — the web tier (the cloudbongos.service EnvironmentFile)
44
+ // box.env — the control-plane runners; deliberately unreadable by the web tier
45
+ // (InaccessiblePaths=, task 1004176 / ADR 0327 §5)
46
+ // spaces.env — the offsite-backup timer
47
+ // `need`: required (the instance is broken without it) · recommended (a named feature
48
+ // is off without it) · optional.
49
+ //
50
+ // Every name here is documented in docs/credentials.md; the test fails if either side
51
+ // gains a name the other lacks. Non-host credentials (GitHub Actions secrets, a
52
+ // builder's own machine) are in the doc only — no host sweep can see them.
53
+ const CREDENTIALS = [
54
+ {
55
+ id: 'github-sign-in', group: 'sign-in', file: 'web.env', need: 'required',
56
+ names: ['GITHUB_CLIENT_ID', 'GITHUB_CLIENT_SECRET'], prefixed: true,
57
+ purpose: 'GitHub OAuth app that signs builders in (src/bongos/auth-config.js)',
58
+ },
59
+ {
60
+ id: 'hub-federation', group: 'sign-in', file: 'web.env', need: 'required',
61
+ names: ['HUB_ORIGIN', 'HUB_CLIENT_ID', 'HUB_CLIENT_SECRET', 'HUB_PUBLIC_KEY'], prefixed: true,
62
+ purpose: 'sign-in federated through the Cloud Bongos hub instead of an own GitHub app (ADR 0141 §4)',
63
+ },
64
+ {
65
+ id: 'publish-app', group: 'publish', file: 'web.env', need: 'required',
66
+ names: ['GITHUB_APP_ID', 'GITHUB_APP_PRIVATE_KEY'],
67
+ purpose: 'GitHub App the server publishes builder branches and PRs with (modules/lifecycle/github-push.js); preferred',
68
+ },
69
+ {
70
+ id: 'publish-pat', group: 'publish', file: 'web.env', need: 'required',
71
+ names: ['GITHUB_PUSH_TOKEN'],
72
+ purpose: 'fine-grained PAT fallback for the same publish path, kept for cutover',
73
+ },
74
+ {
75
+ id: 'repo-pin', file: 'web.env', need: 'required',
76
+ names: ['GITHUB_OWNER', 'GITHUB_REPO'], prefixed: true, notSecret: true,
77
+ purpose: "which GitHub repo this instance is — mandatory where the core runs from node_modules, which has no git remote to read (src/bongos/repo-info.js)",
78
+ },
79
+ {
80
+ id: 'secret-box-key', file: 'web.env', need: 'required',
81
+ names: ['BUILDER_SECRET_KEY'], keyFile: 'builder-secret.key',
82
+ purpose: "master key encrypting builders' stored AI keys and GitHub tokens (src/bongos/secret-box.js); the key file in the service user's config dir also counts",
83
+ },
84
+ {
85
+ id: 'database-password', file: 'web.env', need: 'optional',
86
+ names: ['PGPASSWORD'],
87
+ purpose: "the instance's own Postgres role (src/bongos/pool.js); absent means local peer auth",
88
+ },
89
+ {
90
+ id: 'database-url', file: 'web.env', need: 'optional',
91
+ names: ['DATABASE_URL'],
92
+ purpose: 'a full Postgres connection string, when the database is not local',
93
+ },
94
+ {
95
+ id: 'discord-bot', file: 'web.env', need: 'recommended',
96
+ names: ['DISCORD_BOT_TOKEN'],
97
+ purpose: 'the Discord bot (modules/discord); also readable from discord-bot.json in the config dir',
98
+ },
99
+ {
100
+ id: 'discord-oauth', file: 'web.env', need: 'optional',
101
+ names: ['DISCORD_CLIENT_ID', 'DISCORD_CLIENT_SECRET'],
102
+ purpose: 'linking a Discord account at sign-in (src/bongos/auth-discord.js)',
103
+ },
104
+ {
105
+ id: 'discord-ship-news', file: 'web.env', need: 'optional',
106
+ names: ['DISCORD_SHIP_NEWS_WEBHOOK'],
107
+ purpose: '#ship-news posts (scripts/discord/lib.js); discord-webhooks.json in the config dir also works',
108
+ },
109
+ {
110
+ id: 'prelaunch-password', file: 'web.env', need: 'optional',
111
+ names: ['PRELAUNCH_PASSWORD'], legacyNames: ['STEALTH_PASSWORD'],
112
+ purpose: 'the pre-launch site password (src/bongos/prelaunch-gate.js); STEALTH_PASSWORD is the deprecated alias',
113
+ },
114
+ {
115
+ id: 'metrics-token', file: 'web.env', need: 'optional',
116
+ names: ['METRICS_TOKEN'],
117
+ purpose: 'bearer token for a remote Prometheus scrape of /metrics (src/bongos/metrics.js)',
118
+ },
119
+ {
120
+ id: 'hub-signing-key', file: 'web.env', need: 'optional',
121
+ names: ['HUB_SIGNING_KEY'], prefixed: true,
122
+ purpose: 'the HUB only: signs the assertions federated instances verify (modules/platform-identity/hub-keys.js)',
123
+ },
124
+ {
125
+ id: 'digitalocean', file: 'box.env', need: 'recommended',
126
+ names: ['DO_API_TOKEN'],
127
+ purpose: 'creates and destroys project servers (scripts/gds/do-api.js, provisioning); control plane only',
128
+ },
129
+ {
130
+ id: 'cloudflare', file: 'box.env', need: 'recommended',
131
+ names: ['CLOUDFLARE_API_TOKEN'],
132
+ purpose: 'DNS records for project addresses (scripts/gds/provision-net.js); control plane only',
133
+ },
134
+ {
135
+ id: 'offsite-backup', file: 'spaces.env', need: 'optional',
136
+ names: ['SPACES_KEY', 'SPACES_SECRET', 'SPACES_BUCKET'],
137
+ // The same file also carries the region, the endpoint and the age RECIPIENT the dumps
138
+ // are encrypted to. None is a secret (a recipient is a public key), so none is swept.
139
+ purpose: 'the weekly offsite copy of the database dumps (scripts/gds/db-backup-offsite.sh)',
140
+ },
141
+ ];
142
+
143
+ const ENV_FILES = ['web.env', 'box.env', 'spaces.env'];
144
+
145
+ // ---- pure core ------------------------------------------------------------------------
146
+
147
+ // parseEnvFile(text) → { NAME: value } for a systemd EnvironmentFile: KEY=VALUE lines,
148
+ // `#` comments, blank lines, optional surrounding quotes. Values are held only long enough
149
+ // to test for emptiness; nothing returned by the report functions below contains one.
150
+ function parseEnvFile(text) {
151
+ const out = {};
152
+ for (const raw of String(text || '').split(/\r?\n/)) {
153
+ const line = raw.trim();
154
+ if (!line || line.startsWith('#')) continue;
155
+ const eq = line.indexOf('=');
156
+ if (eq <= 0) continue;
157
+ const key = line.slice(0, eq).trim().replace(/^export\s+/, '');
158
+ let value = line.slice(eq + 1).trim();
159
+ if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value[value.length - 1] === value[0]) {
160
+ value = value.slice(1, -1);
161
+ }
162
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) out[key] = value;
163
+ }
164
+ return out;
165
+ }
166
+
167
+ // Every spelling a name answers to, in the order the server tries them.
168
+ function spellingsOf(name, row, prefix) {
169
+ if (row.prefixed) return [...new Set([prefix, ...LEGACY_ENV_PREFIXES])].map((p) => `${p}_${name}`);
170
+ return [name, ...(row.legacyNames || [])];
171
+ }
172
+
173
+ // checkRow(row, env, { prefix, keyFileExists }) → { state, satisfiedBy, missing }
174
+ // state: 'set' | 'empty' | 'missing'. satisfiedBy / missing are variable NAMES.
175
+ function checkRow(row, env, { prefix = envPrefix(), keyFileExists = false } = {}) {
176
+ const satisfiedBy = [];
177
+ const missing = [];
178
+ let anyEmpty = false;
179
+ for (const name of row.names) {
180
+ const spellings = spellingsOf(name, row, prefix);
181
+ const value = row.prefixed ? resolveEnv(name, { env, prefix, warn: () => {} }) : spellings.map((s) => env[s]).find((v) => v);
182
+ if (value) {
183
+ satisfiedBy.push(spellings.find((s) => env[s] === value) || spellings[0]);
184
+ } else {
185
+ if (spellings.some((s) => s in env)) anyEmpty = true;
186
+ missing.push(spellings[0]);
187
+ }
188
+ }
189
+ if (missing.length && row.keyFile && keyFileExists) {
190
+ return { state: 'set', satisfiedBy: [`${row.keyFile} (key file)`], missing: [] };
191
+ }
192
+ const state = missing.length === 0 ? 'set' : anyEmpty ? 'empty' : 'missing';
193
+ return { state, satisfiedBy, missing };
194
+ }
195
+
196
+ // sweep({ files, prefix, keyFileExists }) → { rows, groups, unreadable, ok }
197
+ // files: { 'web.env': { env } | { error } } — the parsed files, or why one could not be read.
198
+ function sweep({ files, prefix = envPrefix(), keyFileExists = false, credentials = CREDENTIALS }) {
199
+ const unreadable = Object.entries(files).filter(([, f]) => f.error).map(([name, f]) => ({ file: name, error: f.error }));
200
+ const rows = credentials.map((row) => {
201
+ const f = files[row.file];
202
+ if (!f || f.error) return { ...pick(row), state: 'unknown', satisfiedBy: [], missing: [] };
203
+ return { ...pick(row), ...checkRow(row, f.env, { prefix, keyFileExists }) };
204
+ });
205
+ // A group is satisfied when any member is; its members are not individually required.
206
+ const groups = {};
207
+ for (const r of rows) {
208
+ if (!r.group) continue;
209
+ groups[r.group] = groups[r.group] || { satisfied: false, unknown: false, members: [] };
210
+ groups[r.group].members.push(r.id);
211
+ if (r.state === 'set') groups[r.group].satisfied = true;
212
+ if (r.state === 'unknown') groups[r.group].unknown = true;
213
+ }
214
+ const requiredMissing = rows.filter((r) => r.need === 'required' && r.state !== 'set' && r.state !== 'unknown'
215
+ && !(r.group && groups[r.group].satisfied));
216
+ const ok = requiredMissing.length === 0 && unreadable.length === 0;
217
+ return { rows, groups, unreadable, requiredMissing: requiredMissing.map((r) => r.id), ok };
218
+ }
219
+
220
+ function pick(row) {
221
+ return { id: row.id, file: row.file, need: row.need, group: row.group || null, purpose: row.purpose, notSecret: !!row.notSecret };
222
+ }
223
+
224
+ // format(result) → the human report. Built only from ids, names, states and the fixed
225
+ // purpose text — never from a parsed value.
226
+ function format(result, { etc } = {}) {
227
+ const mark = { set: '✓', empty: '!', missing: '✗', unknown: '?' };
228
+ const lines = [`Credential presence — names only, never values (${etc || 'env files'})`, ''];
229
+ for (const file of ENV_FILES) {
230
+ const rows = result.rows.filter((r) => r.file === file);
231
+ if (!rows.length) continue;
232
+ const bad = result.unreadable.find((u) => u.file === file);
233
+ lines.push(` ${file}${bad ? ` — could not read: ${bad.error}` : ''}`);
234
+ for (const r of rows) {
235
+ const grp = r.group ? result.groups[r.group] : null;
236
+ const covered = grp && grp.satisfied && r.state !== 'set' ? ` (not needed: another ${r.group} option is set)` : '';
237
+ const detail = r.state === 'set' ? `via ${r.satisfiedBy.join(', ')}`
238
+ : r.state === 'unknown' ? 'file unreadable'
239
+ : `${r.state === 'empty' ? 'set but EMPTY' : 'not set'}: ${r.missing.join(', ')}`;
240
+ lines.push(` ${mark[r.state]} ${r.id.padEnd(20)} ${r.need.padEnd(11)} ${detail}${covered}`);
241
+ }
242
+ lines.push('');
243
+ }
244
+ if (result.requiredMissing.length) lines.push(`REQUIRED and missing: ${result.requiredMissing.join(', ')} — see docs/credentials.md for what each is and where it comes from.`);
245
+ if (result.unreadable.length) lines.push('Some env files could not be read, so this answer is incomplete — rerun as a user that can read them (usually sudo).');
246
+ if (result.ok) lines.push('Every required credential is present.');
247
+ lines.push('Deeper check of the publish credential (can it read Actions?): node scripts/gds/publish-credential-check.js');
248
+ return lines.join('\n');
249
+ }
250
+
251
+ // readFiles(etc) → the `files` shape sweep() takes, read from <etc>/<name>.
252
+ function readFiles(etc, { fsImpl = fs } = {}) {
253
+ const files = {};
254
+ for (const name of ENV_FILES) {
255
+ const p = path.join(etc, name);
256
+ try {
257
+ files[name] = { env: parseEnvFile(fsImpl.readFileSync(p, 'utf8')) };
258
+ } catch (e) {
259
+ // An absent spaces.env just means offsite backups were never set up; an absent
260
+ // web.env means this is not the host (or the wrong --etc).
261
+ files[name] = e.code === 'ENOENT' && name !== 'web.env' ? { env: {} } : { error: e.code || e.message };
262
+ }
263
+ }
264
+ return files;
265
+ }
266
+
267
+ module.exports = { CREDENTIALS, ENV_FILES, parseEnvFile, checkRow, sweep, format, readFiles };
268
+
269
+ if (require.main === module) {
270
+ const etcArg = arg('--etc');
271
+ const etc = etcArg && etcArg !== true ? etcArg : path.join('/etc', configDirName());
272
+ const files = readFiles(etc);
273
+ const keyArg = arg('--secret-key-file');
274
+ const keyFile = keyArg && keyArg !== true ? keyArg : configPath('builder-secret.key');
275
+ let keyFileExists = false;
276
+ try { keyFileExists = fs.statSync(keyFile).isFile(); } catch (_) { /* absent */ }
277
+ const result = sweep({ files, keyFileExists });
278
+ if (hasFlag('--json')) console.log(JSON.stringify({ etc, ...result }, null, 2));
279
+ else console.log(format(result, { etc }));
280
+ cliExit(result.unreadable.length ? 2 : result.ok ? 0 : 1);
281
+ }
@@ -42,9 +42,13 @@
42
42
 
43
43
  set -eu
44
44
 
45
+ # The range rides main-audit.js's --range flag, never an env var (task 1001441): a
46
+ # shell cannot compute the instance's branded env prefix (<PREFIX>_AUDIT_RANGE), and
47
+ # argv needs none. AUDIT_RANGE below is this script's own variable, not exported.
48
+ AUDIT_RANGE=""
45
49
  if [ -n "${OTB_DEPLOY_PREV_SHA:-}" ]; then
46
- OTB_AUDIT_RANGE="${OTB_DEPLOY_PREV_SHA}..HEAD"; export OTB_AUDIT_RANGE
47
- echo "main audit: range ${OTB_AUDIT_RANGE} (exact deploy delta)"
50
+ AUDIT_RANGE="${OTB_DEPLOY_PREV_SHA}..HEAD"
51
+ echo "main audit: range ${AUDIT_RANGE} (exact deploy delta)"
48
52
  else
49
53
  echo "main audit: OTB_DEPLOY_PREV_SHA not set — falling back to the wide window."
50
54
  echo " (Recommended: capture OTB_DEPLOY_PREV_SHA=\$(git rev-parse HEAD) BEFORE the pull;"
@@ -64,7 +68,10 @@ if [ ! -f "$MAIN_AUDIT" ]; then
64
68
  exit 1
65
69
  fi
66
70
 
67
- if node "$MAIN_AUDIT" --verify-head --db ${STRICT}; then
71
+ if [ -n "$AUDIT_RANGE" ]; then set -- --range "$AUDIT_RANGE"; else set --; fi
72
+ # ${1+"$@"}, not "$@": an empty "$@" under set -u is an unbound-variable error in
73
+ # older /bin/sh (bash 3.2, macOS).
74
+ if node "$MAIN_AUDIT" --verify-head --db ${STRICT} ${1+"$@"}; then
68
75
  echo "OK main audit clean${STRICT:+ (strict)} — proceeding with deploy."
69
76
  exit 0
70
77
  fi
@@ -72,7 +79,7 @@ fi
72
79
  echo ""
73
80
  echo "X DEPLOY ABORTED (SEC #863): a below-Metic${STRICT:+ or unresolved} author modified a"
74
81
  echo " permission-sensitive path in this deploy. Investigate and revert before deploying:"
75
- echo " node $MAIN_AUDIT --range \"\${OTB_AUDIT_RANGE:-HEAD~200..HEAD}\" --revert"
82
+ echo " node $MAIN_AUDIT --range \"\${AUDIT_RANGE:-HEAD~200..HEAD}\" --revert"
76
83
 
77
84
  if [ -n "${OTB_DEPLOY_PREV_SHA:-}" ] && [ "${OTB_GATE_NO_RESTORE:-0}" != "1" ]; then
78
85
  if git rev-parse --verify --quiet "${OTB_DEPLOY_PREV_SHA}^{commit}" >/dev/null; then
@@ -35,6 +35,9 @@ const DEFAULT_PORT = 3000;
35
35
  // scripts/gds/ -> the core package root (holds src/platform-server.js + scripts/migrate.sh).
36
36
  const CORE_ROOT = path.resolve(__dirname, '..', '..');
37
37
 
38
+ // The one role the Windows Postgres installer creates (task 1002707).
39
+ const WINDOWS_PG_USER = 'postgres';
40
+
38
41
  function readJsonSafe(p, f = fs) {
39
42
  try { return JSON.parse(f.readFileSync(p, 'utf8')); }
40
43
  catch { return null; }
@@ -102,9 +105,10 @@ function deriveDbName({ brandingDbName, instanceRoot, override } = {}) {
102
105
  }
103
106
 
104
107
  // Resolve every parameter a local run needs, from branding + modules + env + argv.
105
- // Pure: all IO comes through injected deps (f/env/userInfo/argv). PGHOST defaults to
106
- // TCP 'localhost' (NOT the Linux peer socket the pool defaults to) so a Homebrew Mac
107
- // Postgres is reachable; an explicit PGHOST/PGUSER in the environment still wins.
108
+ // Pure: all IO comes through injected deps (f/env/userInfo/argv/platform). PGHOST
109
+ // defaults to TCP 'localhost' (NOT the Linux peer socket the pool defaults to) so a
110
+ // Homebrew Mac Postgres is reachable; an explicit PGHOST/PGUSER in the environment
111
+ // still wins.
108
112
  function resolveRunParams({
109
113
  instanceRoot,
110
114
  coreRoot = CORE_ROOT,
@@ -113,6 +117,7 @@ function resolveRunParams({
113
117
  userInfo = os.userInfo,
114
118
  argv = [],
115
119
  mode = null,
120
+ platform = process.platform,
116
121
  } = {}) {
117
122
  const facts = loadBrandingFacts({ instanceRoot, coreRoot, f });
118
123
  const modules = readJsonSafe(path.join(instanceRoot, 'config', 'modules.json'), f) || {};
@@ -128,6 +133,10 @@ function resolveRunParams({
128
133
  const dbName = deriveDbName({ brandingDbName: facts.brandingDbName, instanceRoot, override: dbOverride });
129
134
 
130
135
  let pgUser = env.PGUSER;
136
+ // Windows (task 1002707): the Postgres installer creates exactly one role,
137
+ // `postgres`. Your Windows login name is never a role there, so defaulting to
138
+ // it would fail every connection with 'role "<you>" does not exist'.
139
+ if (!pgUser && platform === 'win32') pgUser = WINDOWS_PG_USER;
131
140
  if (!pgUser) { try { pgUser = userInfo().username; } catch { pgUser = undefined; } }
132
141
 
133
142
  const publicOrigin = facts.publicOrigin.replace(/\/+$/, '');
@@ -307,6 +316,195 @@ function depsInstallPlan({ instanceRoot, f = fs } = {}) {
307
316
  : { needed: true, cmd: 'install', args: ['install'] };
308
317
  }
309
318
 
319
+ // ========================= WINDOWS (task 1002707) =========================
320
+ // `bongos dev` is the local-hall path every project without an address is told to
321
+ // run (ADR 0323 made bring-your-own-host the free default), so a Windows owner has
322
+ // to be able to one-command it too. Four things differ on Windows, and each is a
323
+ // pure decision here so tests/dev_windows.mjs proves it on any OS:
324
+ // 1. npm is a .cmd shim, which a plain spawn cannot start;
325
+ // 2. the bash for migrate.sh is Git Bash, and the `bash.exe` on PATH may be WSL's
326
+ // launcher, which cannot reach a Windows Postgres or node;
327
+ // 3. the Postgres installer neither puts psql on PATH nor creates a role named
328
+ // after you: its one role is `postgres`, behind the password chosen at install;
329
+ // 4. Postgres runs as a Windows service, not under Homebrew or systemd.
330
+ // Docker (docs/recipes/self-host.md) stays the fallback when none of this fits.
331
+ // Paths are built with path.win32 so the answers are Windows-shaped on any host.
332
+
333
+ // Windows env names are case-insensitive (`Path`, `ProgramFiles`), but a copied or
334
+ // injected env is a plain object. Read it the way Windows would.
335
+ function envGet(env, name) {
336
+ if (!env) return undefined;
337
+ if (env[name] !== undefined) return env[name];
338
+ const key = Object.keys(env).find((k) => k.toLowerCase() === name.toLowerCase());
339
+ return key === undefined ? undefined : env[key];
340
+ }
341
+
342
+ // How to run `npm <args>`. On Windows `npm` is `npm.cmd`, and Node will not spawn a
343
+ // .cmd without a shell: bare `npm` is ENOENT and `npm.cmd` is EINVAL since the
344
+ // CVE-2024-27980 fix (docs/recipes/windows-builders.md). Prefer npm's own entry
345
+ // point run by node (npm-cli.js ships beside node.exe), which needs no shell at
346
+ // all; else fall back to the shim through cmd.exe, as ONE command string so Node
347
+ // never concatenates an args array unescaped (DEP0190). The args are fixed verbs
348
+ // ('ci' / 'install'), never user input. Mac/Linux keep the plain spawn.
349
+ function npmInvocation(args = [], { platform = process.platform, execPath = process.execPath, exists = fs.existsSync } = {}) {
350
+ if (platform !== 'win32') return { cmd: 'npm', args: [...args], shell: false };
351
+ const npmCli = path.win32.join(path.win32.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
352
+ if (exists(npmCli)) return { cmd: execPath, args: [npmCli, ...args], shell: false };
353
+ return { cmd: ['npm', ...args].join(' '), args: [], shell: true };
354
+ }
355
+
356
+ // Is this bash.exe WSL's launcher? C:\Windows\System32\bash.exe (and the
357
+ // WindowsApps alias) start a Linux distro that cannot see Windows' node, psql or
358
+ // PATH, so migrate.sh would "run" there and fail naming nothing useful.
359
+ function isWslBashLauncher(p) {
360
+ const s = String(p).replace(/\\/g, '/').toLowerCase();
361
+ return /\/windows\/(system32|sysnative)\/bash\.exe$/.test(s) || s.includes('/windowsapps/');
362
+ }
363
+
364
+ // Which bash runs migrate.sh. Mac/Linux: `bash` on PATH, as before. Windows: Git
365
+ // Bash, the shell Windows builders already use and the one migrate.sh is written to
366
+ // handle. Looked for cheapest-to-trust first: the Git Bash this terminal runs in
367
+ // (EXEPATH), Git's install folders, beside the `git` on PATH, then any non-WSL bash
368
+ // on PATH. Returns { ok, bin, source }, or { ok: false, wslOnly } when nothing
369
+ // usable exists (wslOnly = the only bash seen was WSL's, which deserves a sentence).
370
+ function resolveBash({ platform = process.platform, env = process.env, has = () => false, whereAll = () => [], exists = fs.existsSync } = {}) {
371
+ if (platform !== 'win32') return has('bash') ? { ok: true, bin: 'bash', source: 'PATH' } : { ok: false, wslOnly: false };
372
+ const w = path.win32;
373
+ const candidates = [];
374
+ const exe = envGet(env, 'EXEPATH'); // set by Git Bash: its install root or its bin folder
375
+ if (exe) candidates.push([w.join(exe, 'bash.exe'), 'Git Bash'], [w.join(exe, 'bin', 'bash.exe'), 'Git Bash']);
376
+ for (const base of [envGet(env, 'ProgramFiles'), envGet(env, 'ProgramW6432'), envGet(env, 'ProgramFiles(x86)')]) {
377
+ if (base) candidates.push([w.join(base, 'Git', 'bin', 'bash.exe'), 'Git for Windows']);
378
+ }
379
+ const local = envGet(env, 'LOCALAPPDATA');
380
+ if (local) candidates.push([w.join(local, 'Programs', 'Git', 'bin', 'bash.exe'), 'Git for Windows']);
381
+ // git.exe sits in <root>\cmd, <root>\bin or <root>\mingw64\bin; bash in <root>\bin.
382
+ for (const git of whereAll('git')) {
383
+ let dir = w.dirname(git);
384
+ for (let i = 0; i < 3; i++) { candidates.push([w.join(dir, 'bin', 'bash.exe'), 'Git for Windows']); dir = w.dirname(dir); }
385
+ }
386
+ let wslSeen = false;
387
+ for (const b of whereAll('bash')) {
388
+ if (isWslBashLauncher(b)) wslSeen = true;
389
+ else candidates.push([b, 'PATH']);
390
+ }
391
+ for (const [bin, source] of candidates) {
392
+ if (exists(bin)) return { ok: true, bin, source };
393
+ }
394
+ return { ok: false, wslOnly: wslSeen };
395
+ }
396
+
397
+ // migrate.sh's path as Git Bash must receive it. The script finds the core with
398
+ // `dirname "$0"`, and bash's dirname splits on `/` only: handed C:\x\scripts\migrate.sh
399
+ // it answers ".", so the script would look for migrations under whatever folder it
400
+ // was started in. Forward slashes (C:/x/scripts/migrate.sh) keep that working.
401
+ function bashScriptPath(p, platform = process.platform) {
402
+ return platform === 'win32' ? String(p).replace(/\\/g, '/') : p;
403
+ }
404
+
405
+ // The Postgres Windows installer (postgresql.org/download/windows) puts the server
406
+ // and its client tools in %ProgramFiles%\PostgreSQL\<version>\bin but does NOT add
407
+ // that folder to PATH, so `psql` looks missing on a machine where Postgres is
408
+ // installed and running. Find those folders, newest first, so dev.js can put the
409
+ // tools on PATH for this run instead of asking an owner to edit PATH by hand. Each
410
+ // entry also names the installer's Windows service (postgresql-x64-<version>).
411
+ function windowsPgInstalls({ env = process.env, readdir = (d) => fs.readdirSync(d), exists = fs.existsSync } = {}) {
412
+ const w = path.win32;
413
+ const seen = new Set();
414
+ const out = [];
415
+ for (const base of [envGet(env, 'ProgramFiles'), envGet(env, 'ProgramW6432'), envGet(env, 'ProgramFiles(x86)')]) {
416
+ if (!base) continue;
417
+ const root = w.join(base, 'PostgreSQL');
418
+ let entries;
419
+ try { entries = readdir(root); } catch { continue; }
420
+ for (const name of entries) {
421
+ const version = Number.parseFloat(String(name));
422
+ if (!Number.isFinite(version) || version <= 0) continue;
423
+ const bin = w.join(root, String(name), 'bin');
424
+ if (seen.has(bin.toLowerCase()) || !exists(w.join(bin, 'psql.exe'))) continue;
425
+ seen.add(bin.toLowerCase());
426
+ out.push({ version, bin, service: `postgresql-x64-${name}` });
427
+ }
428
+ }
429
+ return out.sort((a, b) => b.version - a.version);
430
+ }
431
+
432
+ // psql/createdb connection flags: -h/-U only when we have them (libpq fills the
433
+ // rest). On Windows add -w (never prompt): the installer's `postgres` role is
434
+ // password-only, and a console prompt is a trap there. psql would take a typed
435
+ // password that migrations, the server and every later run never see. Failing fast
436
+ // lets dev.js say which password to set (pgAuthHint). pg_isready has no -w flag, so
437
+ // its caller passes { forReady: true }.
438
+ function pgClientArgs(params, { platform = process.platform, forReady = false } = {}) {
439
+ const a = ['-h', params.pgHost];
440
+ if (params.pgUser) a.push('-U', params.pgUser);
441
+ if (platform === 'win32' && !forReady) a.push('-w');
442
+ return a;
443
+ }
444
+
445
+ // The shared "give it your Postgres password" lines (Windows installs only).
446
+ const WINDOWS_PGPASSWORD_LINES = [
447
+ 'Give bongos dev that password in the terminal you run it from, then re-run it:',
448
+ ' PowerShell: $env:PGPASSWORD = "<that password>"; npx bongos dev',
449
+ ' Git Bash: PGPASSWORD="<that password>" npx bongos dev',
450
+ 'To keep it for next time: setx PGPASSWORD "<that password>" (then open a new terminal).',
451
+ ];
452
+
453
+ // What to tell someone whose Postgres client tools are missing, per platform, so a
454
+ // Windows owner gets Windows steps instead of a Homebrew line.
455
+ function pgInstallHint(platform = process.platform) {
456
+ if (platform === 'darwin') return ['brew install postgresql@16 && brew services start postgresql@16'];
457
+ if (platform === 'win32') {
458
+ return [
459
+ 'Download the Windows installer from https://www.postgresql.org/download/windows/ and run it.',
460
+ 'Keep the default port (5432), and note the password it asks you to choose for the "postgres" user.',
461
+ ...WINDOWS_PGPASSWORD_LINES,
462
+ 'Rather not install Postgres? The Docker path in docs/recipes/self-host.md runs everything in containers.',
463
+ ];
464
+ }
465
+ return ['(Debian/Ubuntu) sudo apt-get install -y postgresql (or your distro’s package)'];
466
+ }
467
+
468
+ // How to start Postgres by hand when dev.js could not, per platform.
469
+ function pgStartHint(platform = process.platform, { pgInstalls = [] } = {}) {
470
+ if (platform === 'darwin') return ['brew services start postgresql@16'];
471
+ if (platform === 'win32') {
472
+ const svc = (pgInstalls[0] && pgInstalls[0].service) || 'postgresql-x64-16';
473
+ return [
474
+ `Press Win+R, type services.msc, find "${svc}" in the list, and click Start.`,
475
+ `Or, in a PowerShell opened with "Run as administrator": Start-Service ${svc}`,
476
+ ];
477
+ }
478
+ return ['sudo service postgresql start'];
479
+ }
480
+
481
+ // Windows only: a createdb/psql refused on authentication almost always means
482
+ // PGPASSWORD is unset or wrong for the installer's `postgres` role. [] otherwise,
483
+ // so Mac/Linux output is unchanged.
484
+ function pgAuthHint(platform = process.platform, stderr = '') {
485
+ if (platform !== 'win32') return [];
486
+ if (!/password authentication failed|no password supplied|role .* does not exist/i.test(String(stderr))) return [];
487
+ return [
488
+ 'Postgres on Windows has one user, "postgres", protected by the password you chose when you installed it.',
489
+ ...WINDOWS_PGPASSWORD_LINES,
490
+ 'Using a different Postgres user? Set PGUSER to it the same way.',
491
+ ];
492
+ }
493
+
494
+ // No usable bash for migrations. Windows only (Mac and Linux ship bash); names Git
495
+ // for Windows first and Docker as the fallback, and explains a WSL-only bash.
496
+ function bashMissingHint(platform = process.platform, { wslOnly = false } = {}) {
497
+ if (platform !== 'win32') return [];
498
+ const lines = [];
499
+ if (wslOnly) lines.push('The only bash found belongs to WSL (C:\\Windows\\System32\\bash.exe). It runs Linux, so it cannot reach a Windows Postgres or Node.');
500
+ lines.push(
501
+ 'Install Git for Windows from https://git-scm.com/download/win (it includes Git Bash, which runs the migrations),',
502
+ 'then open a new terminal and re-run `npx bongos dev`.',
503
+ 'Or skip the local tools entirely with the Docker path: docs/recipes/self-host.md.',
504
+ );
505
+ return lines;
506
+ }
507
+
310
508
  // ===================== SERVER MODE (task 1002398, ADR 0162) =====================
311
509
  // The SAME boot path — deps -> Postgres -> createdb -> migrate -> env -> server ->
312
510
  // health — retargeted at a real host. Four decisions differ from local, and every
@@ -436,13 +634,19 @@ function withSudo(step, { isRoot = false, sudo = true } = {}) {
436
634
  // Ordered candidates for "start Postgres", best-first for the platform. dev.js
437
635
  // tries each until pg_isready passes. Homebrew keeps its own branch there (the
438
636
  // formula name has to be discovered at runtime); everything else is decided here.
439
- function pgStartPlan({ platform = process.platform, has = () => false, env = process.env, isRoot = false } = {}) {
637
+ // On Windows, Postgres is a Windows service per installed version (pgInstalls, from
638
+ // windowsPgInstalls). `net start` needs an Administrator terminal; from an ordinary
639
+ // one it fails fast with "Access is denied", and dev.js moves on to pgStartHint.
640
+ function pgStartPlan({ platform = process.platform, has = () => false, env = process.env, isRoot = false, pgInstalls = [] } = {}) {
440
641
  const out = [];
441
642
  if (platform === 'darwin' && has('brew')) out.push({ cmd: 'brew', args: ['services', 'start'], needsFormula: true, label: 'Homebrew' });
442
643
  if (platform === 'linux') {
443
644
  if (has('systemctl')) out.push(withSudo({ cmd: 'systemctl', args: ['start', 'postgresql'], label: 'systemd' }, { isRoot }));
444
645
  else if (has('service')) out.push(withSudo({ cmd: 'service', args: ['postgresql', 'start'], label: 'service' }, { isRoot }));
445
646
  }
647
+ if (platform === 'win32') {
648
+ for (const inst of pgInstalls) out.push({ cmd: 'net', args: ['start', inst.service], label: `Windows service ${inst.service}` });
649
+ }
446
650
  if (has('pg_ctl') && env.PGDATA) out.push({ cmd: 'pg_ctl', args: ['start', '-w', '-D', env.PGDATA], label: 'pg_ctl' });
447
651
  return out;
448
652
  }
@@ -502,9 +706,12 @@ async function publicHealth(params, deps = {}) {
502
706
  }
503
707
 
504
708
  module.exports = {
505
- HOST, DEFAULT_PORT, CORE_ROOT,
709
+ HOST, DEFAULT_PORT, CORE_ROOT, WINDOWS_PG_USER,
506
710
  readJsonSafe, loadBrandingFacts, parsePort, sanitizeDbName, deriveDbName,
507
711
  resolveRunParams, buildEnv, authWillWork, depsInstallPlan,
712
+ // Windows (task 1002707). envGet stays private — a lookup helper, not a surface.
713
+ npmInvocation, isWslBashLauncher, resolveBash, bashScriptPath, windowsPgInstalls,
714
+ pgClientArgs, pgInstallHint, pgStartHint, pgAuthHint, bashMissingHint,
508
715
  slug, pidPath, logPath, readPid, pidAlive, isRunning, serverEntry,
509
716
  startDetached, stop, health, argFrom,
510
717
  // server mode (task 1002398). withSudo/quoteIdent/shellJoin stay private —