@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.
- package/.bongos-core.json +526 -191
- package/.claude/skills/planning-session/SKILL.md +6 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +32 -0
- package/clients/bongos-client/index.cjs +32 -0
- package/clients/bongos-client/index.d.ts +51 -6
- package/clients/bongos-client/index.mjs +32 -0
- package/docs/adr/0099-delayed-redacted-mirror-export.md +5 -1
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +17 -0
- package/docs/adr/0176-private-repo-deploy-keys.md +2 -0
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0348-the-web-tier-may-look-read-only-at-what-an-owners-render-key-can-see.md +53 -0
- package/docs/adr/0349-a-version-preview-is-a-sandboxed-child-the-web-tier-launches.md +106 -0
- package/docs/adr/0350-the-hub-holds-a-per-project-write-deploy-key-so-a-hosted-upgrade-reaches-github.md +103 -0
- package/docs/adr/README.md +3 -0
- package/docs/api/openapi.json +851 -19
- package/docs/api-reference.md +33 -9
- package/docs/architecture.md +6 -2
- package/docs/copy-inventory.md +620 -553
- package/docs/copy-registry.json +1467 -820
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +6 -0
- package/docs/modules-contract.md +1 -0
- package/docs/page-inventory.json +38 -4
- package/docs/page-readings.json +1674 -1566
- package/migrations/core_260_goals_working_area.sql +63 -0
- package/migrations/core_261_grade_attempts_verdicts.sql +24 -0
- package/migrations/core_262_drop_builder_box_blocked.sql +33 -0
- package/modules/agents/lib/validate.js +43 -0
- package/modules/autonomy/gauge.js +38 -2
- package/modules/grading/grader-subagent.js +37 -2
- package/modules/grading/grader-workers/reader.js +65 -5
- package/modules/hall-ui/public/approval-queue.css +33 -7
- package/modules/hall-ui/public/approval-queue.js +9 -2
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/brand-holes.js +121 -0
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/collab.js +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +6 -1
- package/modules/hall-ui/public/deploy.js +22 -8
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/dom-utils.js +20 -0
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/idea.js +28 -0
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/ideas.js +19 -3
- package/modules/hall-ui/public/index.html +5 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile-nudge.js +21 -4
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/profile.js +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +19 -30
- package/modules/hall-ui/public/settings.js +24 -177
- package/modules/hall-ui/public/settings.states.json +1 -1
- package/modules/hall-ui/public/shell.js +4 -2
- package/modules/hall-ui/public/studio.css +14 -8
- package/modules/hall-ui/public/studio.html +5 -4
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.css +6 -3
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor-lib.js +82 -1
- package/modules/hall-ui/public/tweak-editor.css +142 -11
- package/modules/hall-ui/public/tweak-editor.html +25 -9
- package/modules/hall-ui/public/tweak-editor.js +243 -10
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/hall-ui/records/approval-queue.md +6 -0
- package/modules/hall-ui/records/tweak-editor.md +6 -0
- package/modules/ideas/ratify.js +36 -8
- package/modules/ideas/routes/ratify-goal.js +6 -0
- package/modules/lifecycle/db-analytics.js +79 -10
- package/modules/lifecycle/db-claims.js +48 -1
- package/modules/lifecycle/db-deps-criteria.js +94 -40
- package/modules/lifecycle/db-goals.js +4 -1
- package/modules/lifecycle/db-grade.js +9 -1
- package/modules/lifecycle/db-relevance-flags.js +183 -0
- package/modules/lifecycle/db-versions.js +4 -3
- package/modules/lifecycle/db.js +19 -0
- package/modules/lifecycle/done-when.js +5 -3
- package/modules/lifecycle/est-advisory.js +31 -4
- package/modules/lifecycle/goal-task-relevance-advisory.js +153 -0
- package/modules/lifecycle/goal-task-relevance-judge.js +226 -0
- package/modules/lifecycle/migrations/lifecycle_014_goal_task_relevance_flags.sql +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/claims.js +8 -1
- package/modules/lifecycle/routes/goal-task-relevance.js +72 -0
- package/modules/lifecycle/routes/goals.js +1 -1
- package/modules/lifecycle/routes/tasks.js +27 -24
- package/modules/lifecycle/routes/visuals.js +9 -0
- package/modules/lifecycle/ship-preflight.js +7 -11
- package/modules/lifecycle/task-visuals.js +109 -5
- package/modules/npm-release/module.json +2 -1
- package/modules/npm-release/preview/commands.js +81 -0
- package/modules/npm-release/preview/divert.js +95 -0
- package/modules/npm-release/preview/env.js +78 -0
- package/modules/npm-release/preview/proxy.js +107 -0
- package/modules/npm-release/preview/runtime.js +83 -0
- package/modules/npm-release/preview/supervisor.js +175 -0
- package/modules/npm-release/public/work.js +65 -1
- package/modules/npm-release/routes/preview.js +133 -0
- package/modules/provisioning/app-status.js +83 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/pollers/app-liveness.js +110 -0
- package/modules/provisioning/render-lookup.js +68 -0
- package/modules/provisioning/render-standup.js +44 -1
- package/modules/provisioning/routes/render-standup.js +81 -6
- package/modules/provisioning/starter-bundles.js +4 -1
- package/modules/public-landing/public/account-private.states.json +14 -0
- package/modules/public-landing/public/account.html +513 -0
- package/modules/public-landing/public/account.probes.json +37 -0
- package/modules/public-landing/public/account.states.json +16 -0
- package/modules/public-landing/public/index.html +5 -2
- package/modules/public-landing/public/projects.html +661 -18
- package/modules/public-landing/public/projects.states.json +8 -1
- package/modules/render-deploy/deploys.js +203 -0
- package/modules/render-deploy/migrations/render_deploy_001_app.sql +22 -0
- package/modules/render-deploy/module.json +25 -0
- package/modules/render-deploy/public/deploy.css +10 -0
- package/modules/render-deploy/public/deploy.js +249 -0
- package/modules/render-deploy/render.js +56 -0
- package/modules/render-deploy/routes/act.js +88 -0
- package/modules/render-deploy/routes/app.js +65 -0
- package/modules/render-deploy/routes/history.js +38 -0
- package/modules/specialities/db.js +27 -8
- package/modules/specialities/migrations/specialities_003_skills.sql +46 -0
- package/modules/specialities/routes/specialities.js +51 -3
- package/modules/specialities/skills.js +94 -0
- package/modules/specialities/specialities.js +56 -9
- package/modules/ui-design/kit/fixtures/me-cross-project-private.json +12 -0
- package/modules/ui-design/kit/fixtures/me__cross-project.json +15 -0
- package/modules/ui-design/kit/fixtures/provisioning-instance-render.json +39 -0
- package/modules/ui-design/kit/lib.js +3 -1
- package/modules/ui-design/kit/serve.js +96 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +163 -0
- package/scripts/gds/agents-sync.js +13 -3
- package/scripts/gds/autobongos-run.js +80 -2
- package/scripts/gds/autobongos-service.cmd +12 -0
- package/scripts/gds/copy-apply.js +14 -0
- package/scripts/gds/dev-box-guard.js +3 -2
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/grade-correlation-audit.js +42 -12
- package/scripts/gds/grade-replay.js +1 -1
- package/scripts/gds/provision-core-upgrade.js +38 -7
- package/scripts/gds/provision-pin-key.js +176 -0
- package/scripts/gds/provision-render.js +62 -31
- package/scripts/gds/provision-teardown.js +7 -2
- package/scripts/gds/provision-units.js +31 -0
- package/scripts/gds/provision.js +10 -10
- package/scripts/gds/publish-manifest.js +1 -0
- package/scripts/gds/render-api.js +19 -0
- package/scripts/gds/render-payload.js +39 -65
- package/scripts/gds/run-unit-tests.js +10 -0
- package/scripts/gds/ship-finish.js +19 -20
- package/scripts/gds/smoke-dependencies.sh +37 -8
- package/scripts/gds/status.js +12 -4
- package/scripts/gds/upgrade.js +2 -2
- package/scripts/public-mirror-export.js +7 -1
- package/src/bongos/module-scope-map.js +13 -0
- package/src/bongos/serve-internal.js +3 -1
- package/src/module-api.js +10 -1
- package/src/platform-server.js +39 -0
- package/tests/account_privacy_flags.mjs +10 -6
- package/tests/agents_authoring.mjs +15 -1
- package/tests/agents_sync.mjs +106 -5
- package/tests/agents_validate.mjs +45 -0
- package/tests/api_path_404.mjs +6 -0
- package/tests/autobongos_cadence.mjs +7 -0
- package/tests/autobongos_loop.mjs +136 -1
- package/tests/autonomy_gauge.mjs +62 -0
- package/tests/blocker_hall_live_proof.mjs +275 -0
- package/tests/claim_gate_ci_unblock.mjs +116 -0
- package/tests/claim_gate_rebase.mjs +15 -3
- package/tests/collab_page.mjs +10 -0
- package/tests/conductor_main_e2e.mjs +97 -0
- package/tests/core_262_drop_box_blocked_db.mjs +143 -0
- package/tests/core_upgrade_runner.mjs +1 -0
- package/tests/dependency_writes_atomic.mjs +191 -0
- package/tests/deploy_page_projects.mjs +2 -1
- package/tests/effective_visibility_predicate.mjs +15 -0
- package/tests/est_advisory.mjs +27 -0
- package/tests/goal_map_page.mjs +26 -0
- package/tests/goal_task_relevance.mjs +518 -0
- package/tests/goal_working_area.mjs +146 -0
- package/tests/grade_attempts.mjs +61 -0
- package/tests/grade_attribution.mjs +23 -0
- package/tests/grade_correlation_audit.mjs +54 -1
- package/tests/grader_reader_lens.mjs +55 -2
- package/tests/grader_root_outage.mjs +6 -1
- package/tests/grader_subagent_tools_arg.mjs +102 -0
- package/tests/hall_approval_queue.mjs +54 -2
- package/tests/hall_settings_world.mjs +3 -1
- package/tests/hall_tweak_editor.mjs +316 -11
- package/tests/hub_account_page.mjs +394 -0
- package/tests/idea_detail_page.mjs +64 -0
- package/tests/idea_goal_ratification.mjs +64 -0
- package/tests/idea_spark_hall.mjs +60 -9
- package/tests/ideator_full_idea_shapes_space_proof.mjs +1 -1
- package/tests/landing_page.mjs +6 -3
- package/tests/module-scope-map.mjs +31 -8
- package/tests/module_api.mjs +1 -0
- package/tests/module_loader.mjs +1 -1
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/no_phantom_mirror_workflow.mjs +24 -0
- package/tests/npm_release_preview_commands.mjs +89 -0
- package/tests/npm_release_preview_divert.mjs +164 -0
- package/tests/npm_release_preview_env.mjs +100 -0
- package/tests/npm_release_preview_proxy.mjs +162 -0
- package/tests/npm_release_preview_routes.mjs +208 -0
- package/tests/npm_release_preview_supervisor.mjs +203 -0
- package/tests/pin_write_key.mjs +383 -0
- package/tests/planning_session_skill.mjs +27 -0
- package/tests/platform_boot.mjs +71 -0
- package/tests/profile_nudge_links.mjs +86 -0
- package/tests/profile_ui_cross_project.mjs +1 -1
- package/tests/projects_hub.mjs +2 -2
- package/tests/projects_hub_app_status.mjs +249 -0
- package/tests/projects_hub_app_step.mjs +282 -52
- package/tests/projects_hub_render_connect.mjs +295 -0
- package/tests/provision_render.mjs +121 -28
- package/tests/provision_settings_apply.mjs +62 -0
- package/tests/provisioning_app_status.mjs +204 -0
- package/tests/provisioning_render_route.mjs +126 -6
- package/tests/render_check.mjs +2 -1
- package/tests/render_deploy.mjs +495 -0
- package/tests/ship_preflight.mjs +61 -8
- package/tests/smoke_dependencies_witness.mjs +46 -0
- package/tests/speciality_skills.mjs +214 -0
- package/tests/studio_room_height.mjs +70 -0
- package/tests/task_detail_includes.mjs +10 -0
- package/tests/task_visuals_instance_root.mjs +150 -0
- package/tests/tweak_batch_apply.mjs +21 -0
- package/tests/ui_design_kit.mjs +36 -4
- package/tests/upgrade.mjs +1 -1
- package/tests/upgrade_persist_pin.mjs +24 -0
|
@@ -82,11 +82,113 @@ const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
|
|
|
82
82
|
// ever opens a name that matches it.
|
|
83
83
|
const NAME_RE = /^task-(\d{1,12})-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
|
|
84
84
|
|
|
85
|
-
// Where visuals live
|
|
86
|
-
//
|
|
85
|
+
// Where visuals live: <INSTANCE ROOT>/var/task-visuals (task 1004381).
|
|
86
|
+
//
|
|
87
|
+
// THE BUG THIS FIXES. This used to be path.join(__dirname, '..', '..', 'var',
|
|
88
|
+
// 'task-visuals') — the CORE root. Correct in a single checkout, where core root
|
|
89
|
+
// and instance root are one directory. Wrong the day a site runs the consumer
|
|
90
|
+
// layout: __dirname is then node_modules/@bongos/core/modules/lifecycle, so every
|
|
91
|
+
// visual was written INSIDE the installed package, and every core upgrade (npm ci
|
|
92
|
+
// replaces the package) silently erased them all. Seen live on cloudbongos.com:
|
|
93
|
+
// eight page-tweak renders attached on 1.19.1076 were 404 five upgrades later.
|
|
94
|
+
// The same failure the doorway's resolveInstanceRoot note records for
|
|
95
|
+
// modules/government; the discord bug-attachments store (the sibling this lib
|
|
96
|
+
// follows) was already moved for it.
|
|
97
|
+
//
|
|
98
|
+
// TASK_VISUALS_DIR stays the override (tests, or an operator who wants the
|
|
99
|
+
// images on another disk). The directory is created on first write, by the
|
|
100
|
+
// server process's own user, with a `*` .gitignore inside it: the instance root
|
|
101
|
+
// is a git checkout whose tree `bongos upgrade` requires to be CLEAN, and an
|
|
102
|
+
// instance's scaffolded .gitignore does not list var/. See ensureVisualsDir.
|
|
103
|
+
let instanceRootCache;
|
|
104
|
+
function instanceRoot() {
|
|
105
|
+
if (instanceRootCache === undefined) {
|
|
106
|
+
try {
|
|
107
|
+
// Through the published doorway (ADR 0083), lazily: the CLI requires this
|
|
108
|
+
// lib for readImageFile and must not pay for the doorway.
|
|
109
|
+
instanceRootCache = require('../../src/module-api').resolveInstanceRoot();
|
|
110
|
+
} catch (e) {
|
|
111
|
+
// A storage-path lookup must never take the server down: fall back to the
|
|
112
|
+
// historical location, which is right in a single checkout.
|
|
113
|
+
instanceRootCache = path.join(__dirname, '..', '..');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return instanceRootCache;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// The old, in-package location. Read only by migrateLegacyVisuals, so a site
|
|
120
|
+
// that wrote visuals there before this fix keeps them.
|
|
121
|
+
function legacyVisualsDir() {
|
|
122
|
+
return path.join(__dirname, '..', '..', 'var', 'task-visuals');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// PURE given its inputs — the store directory for an instance root. Split out so
|
|
126
|
+
// the consumer-layout test can prove where it lands without faking a process.
|
|
127
|
+
function visualsDirFor({ env = process.env, root } = {}) {
|
|
128
|
+
if (env.TASK_VISUALS_DIR) return env.TASK_VISUALS_DIR;
|
|
129
|
+
return path.join(root || instanceRoot(), 'var', 'task-visuals');
|
|
130
|
+
}
|
|
131
|
+
|
|
87
132
|
function visualsDir() {
|
|
88
|
-
return
|
|
89
|
-
|
|
133
|
+
return visualsDirFor();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// mkdir -p the store and, for the DEFAULT store under the instance root, drop a
|
|
137
|
+
// `*` .gitignore in it, so the files never show as untracked in the instance's
|
|
138
|
+
// git tree (an untracked path there halts the next `bongos upgrade` pre-flight).
|
|
139
|
+
// A TASK_VISUALS_DIR override is the operator's own directory and is left bare.
|
|
140
|
+
// Idempotent; the .gitignore write is best-effort.
|
|
141
|
+
const DIR_GITIGNORE = '# Task visuals (task 1004381): runtime data, never committed.\n*\n';
|
|
142
|
+
async function ensureVisualsDir(dir, { env = process.env } = {}) {
|
|
143
|
+
await fs.mkdir(dir, { recursive: true });
|
|
144
|
+
if (env.TASK_VISUALS_DIR) return;
|
|
145
|
+
try {
|
|
146
|
+
await fs.writeFile(path.join(dir, '.gitignore'), DIR_GITIGNORE, { flag: 'wx' });
|
|
147
|
+
} catch (err) { /* EEXIST is the steady state; anything else is non-fatal */ }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// One-time boot move: any stored visual still at the old in-package path is
|
|
151
|
+
// moved into the new store, so a site that attached renders before upgrading to
|
|
152
|
+
// this fix does not lose them on the upgrade after. A no-op when the two paths
|
|
153
|
+
// are the same directory (a single checkout) or the old one is absent. Never
|
|
154
|
+
// overwrites a file already in the new store, never throws: it returns
|
|
155
|
+
// { moved, skipped, failed } for the caller to log.
|
|
156
|
+
//
|
|
157
|
+
// With TASK_VISUALS_DIR set (and no explicit `from`) there is nothing to move:
|
|
158
|
+
// the old code honoured the same override, so no visual was ever written to the
|
|
159
|
+
// legacy path. That also keeps a test that points the store at a temp dir from
|
|
160
|
+
// sweeping a real checkout's var/task-visuals into it.
|
|
161
|
+
async function migrateLegacyVisuals({ from, to = visualsDir(), env = process.env } = {}) {
|
|
162
|
+
const out = { moved: 0, skipped: 0, failed: 0 };
|
|
163
|
+
if (from === undefined) {
|
|
164
|
+
if (env.TASK_VISUALS_DIR) return out;
|
|
165
|
+
from = legacyVisualsDir();
|
|
166
|
+
}
|
|
167
|
+
if (path.resolve(from) === path.resolve(to)) return out;
|
|
168
|
+
let names;
|
|
169
|
+
try { names = await fs.readdir(from); } catch (err) { return out; }
|
|
170
|
+
names = names.filter(isSafeName);
|
|
171
|
+
if (!names.length) return out;
|
|
172
|
+
await ensureVisualsDir(to);
|
|
173
|
+
for (const name of names) {
|
|
174
|
+
const src = path.join(from, name);
|
|
175
|
+
const dest = path.join(to, name);
|
|
176
|
+
try {
|
|
177
|
+
try { await fs.access(dest); out.skipped += 1; continue; } catch (e) { /* absent: move it */ }
|
|
178
|
+
try {
|
|
179
|
+
await fs.rename(src, dest);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (!err || err.code !== 'EXDEV') throw err;
|
|
182
|
+
// Another filesystem: copy, then remove the original.
|
|
183
|
+
await fs.copyFile(src, dest, fsSync.constants.COPYFILE_EXCL);
|
|
184
|
+
await fs.unlink(src);
|
|
185
|
+
}
|
|
186
|
+
out.moved += 1;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
out.failed += 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return out;
|
|
90
192
|
}
|
|
91
193
|
|
|
92
194
|
// PURE — is this a well-formed, server-generated visual name?
|
|
@@ -242,7 +344,7 @@ async function persistImageBuffer(buf, { taskId, contentType, dir = visualsDir()
|
|
|
242
344
|
const screened = screenImage(buf, contentType);
|
|
243
345
|
if (!screened.ok) return screened;
|
|
244
346
|
const file = `task-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
|
|
245
|
-
await
|
|
347
|
+
await ensureVisualsDir(dir);
|
|
246
348
|
await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
|
|
247
349
|
return { ok: true, file, url: urlForName(file), bytes: buf.length };
|
|
248
350
|
}
|
|
@@ -278,6 +380,8 @@ module.exports = {
|
|
|
278
380
|
MAX_BYTES,
|
|
279
381
|
MAX_ALT_LENGTH,
|
|
280
382
|
visualsDir,
|
|
383
|
+
visualsDirFor,
|
|
384
|
+
migrateLegacyVisuals,
|
|
281
385
|
isSafeName,
|
|
282
386
|
taskIdFromName,
|
|
283
387
|
resolveSafe,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/commands.js — the shell commands a preview start runs, as data
|
|
4
|
+
// (task 1004300). Each is `{ cmd, args, env?, cwd? }` for spawn WITHOUT a shell, so a version
|
|
5
|
+
// string can never be parsed as anything but an argument. The supervisor runs them in order.
|
|
6
|
+
//
|
|
7
|
+
// THE COPY IS A DUMP AND A RESTORE, never a template copy. A template copy needs the source
|
|
8
|
+
// database to have no other connections, and the live hall holds a pool open on it for as
|
|
9
|
+
// long as it runs — the copy would fail, or worse, force the live pool off. pg_dump reads
|
|
10
|
+
// under a snapshot and takes no lock the live hall notices.
|
|
11
|
+
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const { PREVIEW_DB } = require('./env');
|
|
14
|
+
|
|
15
|
+
const PACKAGE = '@bongos/core';
|
|
16
|
+
// The strict shape of a published version. Anything else never reaches a command line.
|
|
17
|
+
const VERSION_RE = /^\d+\.\d+\.\d+$/;
|
|
18
|
+
|
|
19
|
+
function assertVersion(version) {
|
|
20
|
+
if (!VERSION_RE.test(String(version))) throw new TypeError(`not a version: ${JSON.stringify(version)}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Where a version's install lives: one directory per version, under the cache root. */
|
|
24
|
+
function versionDir(cacheRoot, version) {
|
|
25
|
+
assertVersion(version);
|
|
26
|
+
return path.join(cacheRoot, version);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The installed core's server entry — what the preview process runs. */
|
|
30
|
+
function entryPath(cacheRoot, version) {
|
|
31
|
+
return path.join(versionDir(cacheRoot, version), 'node_modules', '@bongos', 'core', 'src', 'platform-server.js');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Install one published core version into its own directory. Production dependencies only. */
|
|
35
|
+
function installCommand({ cacheRoot, version }) {
|
|
36
|
+
return { cmd: 'npm', args: ['install', '--prefix', versionDir(cacheRoot, version), `${PACKAGE}@${version}`, '--omit=dev'] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Drop the copy if one is left over — used before a start and on every stop. */
|
|
40
|
+
function dropDbCommand() {
|
|
41
|
+
return { cmd: 'dropdb', args: ['--if-exists', PREVIEW_DB] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** An empty database for the copy. Deliberately no template (see the header). */
|
|
45
|
+
function createDbCommand() {
|
|
46
|
+
return { cmd: 'createdb', args: [PREVIEW_DB] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Snapshot the live database to a custom-format dump file. */
|
|
50
|
+
function dumpCommand({ liveDb, dumpFile }) {
|
|
51
|
+
if (!liveDb) throw new TypeError('dumpCommand: the live database name is required');
|
|
52
|
+
if (liveDb === PREVIEW_DB) throw new Error('refusing to preview from the preview database itself');
|
|
53
|
+
return { cmd: 'pg_dump', args: ['-Fc', '-f', dumpFile, liveDb] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Load the dump into the copy. --no-owner so it needs no role the copy does not have. */
|
|
57
|
+
function restoreCommand({ dumpFile }) {
|
|
58
|
+
return { cmd: 'pg_restore', args: ['--no-owner', '-d', PREVIEW_DB, dumpFile] };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Run the INSTALLED version's migrations against the copy — the point of a preview: a
|
|
63
|
+
* version's migration meets real data here, before it meets the live hall. INIT_CWD names
|
|
64
|
+
* the install directory as the instance root (migrate.sh resolves it from there);
|
|
65
|
+
* DATABASE_URL is blanked because the pool prefers it to PGDATABASE, and PGDATABASE is
|
|
66
|
+
* pinned to the copy so nothing can be applied to the live database by mistake.
|
|
67
|
+
*/
|
|
68
|
+
function migrateCommand({ cacheRoot, version, env }) {
|
|
69
|
+
const dir = versionDir(cacheRoot, version);
|
|
70
|
+
return {
|
|
71
|
+
cmd: 'bash',
|
|
72
|
+
args: [path.join(dir, 'node_modules', '@bongos', 'core', 'scripts', 'migrate.sh')],
|
|
73
|
+
cwd: dir,
|
|
74
|
+
env: { ...env, INIT_CWD: dir, PGDATABASE: PREVIEW_DB, DATABASE_URL: '' },
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = {
|
|
79
|
+
VERSION_RE, PACKAGE, versionDir, entryPath,
|
|
80
|
+
installCommand, dropDbCommand, createDbCommand, dumpCommand, restoreCommand, migrateCommand,
|
|
81
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/divert.js — the provider for the kernel's optional
|
|
4
|
+
// request.divert seam: it decides, per request, whether a request belongs to the live hall
|
|
5
|
+
// or to the running version preview (task 1004300, ADR 0349).
|
|
6
|
+
//
|
|
7
|
+
// A browser is steered by a cookie, bongos_preview=<version>, set when its owner opens the
|
|
8
|
+
// preview. Same origin, so sign-in and cookies just work and no second OAuth app is needed.
|
|
9
|
+
// A request is forwarded only when EVERY one of these holds; otherwise it falls through to
|
|
10
|
+
// the live hall untouched:
|
|
11
|
+
// - the cookie names a version, and that is the version now running
|
|
12
|
+
// - the path is not sign-in (/api/bongos/auth/*) and not this module's own preview routes,
|
|
13
|
+
// so signing in, and getting OUT of a preview, always reach the live hall
|
|
14
|
+
// - the request is not a CLI/API call (an Authorization header): those are never previews
|
|
15
|
+
// - the caller is signed in AND holds core.pin.move — checked on EVERY request, uncached,
|
|
16
|
+
// with the same gates a route would use, so a revoked permission ends the preview for
|
|
17
|
+
// that person on their next click
|
|
18
|
+
// A failed permission check also clears the cookie, so nobody is left stranded in a loop.
|
|
19
|
+
|
|
20
|
+
const { VERSION_RE } = require('./commands');
|
|
21
|
+
const { COOKIE_NAME } = require('./proxy');
|
|
22
|
+
|
|
23
|
+
// The API is served under every prefix the kernel mounts it at: /api/bongos, the /api/bongos/v1
|
|
24
|
+
// versioned form, and the legacy /api/gds. An exclusion that named only one would let the same
|
|
25
|
+
// sign-in or exit request through by another spelling, so each pattern allows the optional
|
|
26
|
+
// version segment (src/bongos/api-prefix.js owns the list; a module cannot import it).
|
|
27
|
+
const AUTH_PATH = /^\/api\/(?:bongos|gds)(?:\/v\d+)?\/auth(?:\/|$)/;
|
|
28
|
+
const PREVIEW_PATH = /^\/api\/(?:bongos|gds)(?:\/v\d+)?\/npm-release\/preview(?:\/|$)/;
|
|
29
|
+
|
|
30
|
+
function cookieVersion(header) {
|
|
31
|
+
for (const part of String(header || '').split(';')) {
|
|
32
|
+
const s = part.trim();
|
|
33
|
+
if (s.startsWith(`${COOKIE_NAME}=`)) {
|
|
34
|
+
const v = s.slice(COOKIE_NAME.length + 1);
|
|
35
|
+
return VERSION_RE.test(v) ? v : null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function pathOf(req) {
|
|
42
|
+
const url = String(req.originalUrl || req.url || '');
|
|
43
|
+
const q = url.indexOf('?');
|
|
44
|
+
return q === -1 ? url : url.slice(0, q);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The Set-Cookie value that removes the steering cookie. */
|
|
48
|
+
function clearCookie() {
|
|
49
|
+
return `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Run an express-style gate to its verdict without letting it write a response of its own:
|
|
53
|
+
// the gates answer a refusal through res.fail, which does not exist this early in the
|
|
54
|
+
// request, and a refusal here means "not yours to preview", not an error page.
|
|
55
|
+
function passes(gate, req) {
|
|
56
|
+
return new Promise((resolve) => {
|
|
57
|
+
let settled = false;
|
|
58
|
+
const done = (ok) => { if (!settled) { settled = true; resolve(ok); } };
|
|
59
|
+
const res = { fail: () => done(false) };
|
|
60
|
+
Promise.resolve(gate(req, res, (err) => done(!err))).catch(() => done(false));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {object} o
|
|
66
|
+
* @param {object} o.supervisor
|
|
67
|
+
* @param {Function} o.forward (req, res) => void — the proxy
|
|
68
|
+
* @param {Function} o.requireBuilder the kernel sign-in gate
|
|
69
|
+
* @param {Function} o.requirePermission the kernel permission gate factory
|
|
70
|
+
* @returns {(req, res, next) => Promise<void>}
|
|
71
|
+
*/
|
|
72
|
+
function createDivert({ supervisor, forward, requireBuilder, requirePermission }) {
|
|
73
|
+
const mayPreview = requirePermission('core.pin.move');
|
|
74
|
+
return async function divert(req, res, next) {
|
|
75
|
+
const wanted = cookieVersion(req.headers.cookie);
|
|
76
|
+
if (!wanted) return next();
|
|
77
|
+
const st = supervisor.status();
|
|
78
|
+
if (st.state !== 'running' || st.version !== wanted) {
|
|
79
|
+
// The preview this cookie pointed at is gone: drop the cookie and serve the live hall.
|
|
80
|
+
res.append('Set-Cookie', clearCookie());
|
|
81
|
+
return next();
|
|
82
|
+
}
|
|
83
|
+
const p = pathOf(req);
|
|
84
|
+
if (AUTH_PATH.test(p) || PREVIEW_PATH.test(p)) return next();
|
|
85
|
+
if (req.headers.authorization) return next();
|
|
86
|
+
if (!(await passes(requireBuilder, req)) || !(await passes(mayPreview, req))) {
|
|
87
|
+
res.append('Set-Cookie', clearCookie());
|
|
88
|
+
return next();
|
|
89
|
+
}
|
|
90
|
+
supervisor.touch();
|
|
91
|
+
return forward(req, res);
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { createDivert, cookieVersion, clearCookie, AUTH_PATH, PREVIEW_PATH };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/env.js — the environment a version preview runs in (task 1004300).
|
|
4
|
+
//
|
|
5
|
+
// A preview is another Bongos process on the same droplet, running an OLDER (or newer)
|
|
6
|
+
// core against a COPY of the live database. Anything that process can reach, it can act
|
|
7
|
+
// on: a Discord webhook, a GitHub token, an AI key, the cron pollers that post, pay and
|
|
8
|
+
// email. So its environment is built FROM NOTHING — an allow-list of the few names a
|
|
9
|
+
// process needs to boot and reach Postgres — and never as a copy of ours with a few
|
|
10
|
+
// names deleted. A deny-list forgets the next secret someone adds; an allow-list cannot.
|
|
11
|
+
//
|
|
12
|
+
// The pollers and the modules that talk outward are then switched off by name, and HOME
|
|
13
|
+
// is pointed at an empty scratch directory so a config file in the real home (the
|
|
14
|
+
// Discord webhook file among them) is not found.
|
|
15
|
+
|
|
16
|
+
// The only names carried over from the live process.
|
|
17
|
+
// PATH so node, and the child's own tooling, resolve
|
|
18
|
+
// NODE_ENV production behaviour, as the live hall runs it
|
|
19
|
+
// PGHOST/PGPORT which Postgres server (the copy lives on the same one)
|
|
20
|
+
// PGUSER/PGPASSWORD the role that connects — needed to open a pool at all
|
|
21
|
+
// PORT and HOST are NOT carried: the preview binds its own, set below.
|
|
22
|
+
const CARRIED = Object.freeze(['PATH', 'NODE_ENV', 'PGHOST', 'PGPORT', 'PGUSER', 'PGPASSWORD']);
|
|
23
|
+
|
|
24
|
+
// The name of the copy database. Fixed, so a leftover from a crash is dropped by the next
|
|
25
|
+
// start instead of piling up.
|
|
26
|
+
const PREVIEW_DB = 'bongos_preview';
|
|
27
|
+
|
|
28
|
+
// Every background poller the platform has, off. Each is read by name in modules/ or
|
|
29
|
+
// src/platform-server.js; a poller that is not listed here would keep running in the copy.
|
|
30
|
+
const POLLERS_OFF = Object.freeze([
|
|
31
|
+
'PUBLISH_RECONCILER_DISABLED',
|
|
32
|
+
'ROUTINE_TIMERS_DISABLED',
|
|
33
|
+
'BOARD_EXPIRY_DISABLED',
|
|
34
|
+
'SEARCH_INDEX_SWEEP_DISABLED',
|
|
35
|
+
'SESSION_UPLOAD_HEALTH_DISABLED',
|
|
36
|
+
'SESSION_REWARD_RECONCILE_DISABLED',
|
|
37
|
+
'PROVISIONING_LIVENESS_DISABLED',
|
|
38
|
+
'PROVISIONING_CATALOG_BACKFILL_DISABLED',
|
|
39
|
+
'CONFLICT_RESOLVE_DISABLED',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
// Modules that talk to the outside world, off. npm-release is off too: a preview must not
|
|
43
|
+
// offer previews of its own.
|
|
44
|
+
const MODULES_OFF = Object.freeze([
|
|
45
|
+
'BONGOS_MODULE_DISCORD',
|
|
46
|
+
'BONGOS_MODULE_AGENTS',
|
|
47
|
+
'BONGOS_MODULE_PROVISIONING',
|
|
48
|
+
'BONGOS_MODULE_NPM_RELEASE',
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the child's environment.
|
|
53
|
+
*
|
|
54
|
+
* @param {object} o
|
|
55
|
+
* @param {object} [o.env] the live process's environment (only CARRIED names are read)
|
|
56
|
+
* @param {number|string} o.port the loopback port the preview listens on
|
|
57
|
+
* @param {string} o.scratchHome an empty directory to serve as HOME
|
|
58
|
+
* @returns {Record<string,string>} a fresh object, never `env` itself
|
|
59
|
+
*/
|
|
60
|
+
function buildPreviewEnv({ env = process.env, port, scratchHome } = {}) {
|
|
61
|
+
if (!port) throw new TypeError('buildPreviewEnv: port is required');
|
|
62
|
+
if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const name of CARRIED) {
|
|
65
|
+
if (env[name] !== undefined && env[name] !== '') out[name] = String(env[name]);
|
|
66
|
+
}
|
|
67
|
+
if (!out.NODE_ENV) out.NODE_ENV = 'production';
|
|
68
|
+
out.PORT = String(port);
|
|
69
|
+
out.HOST = '127.0.0.1';
|
|
70
|
+
out.HOME = scratchHome;
|
|
71
|
+
out.PGDATABASE = PREVIEW_DB;
|
|
72
|
+
out.CHAT_DRY_RUN = '1';
|
|
73
|
+
for (const name of POLLERS_OFF) out[name] = '1';
|
|
74
|
+
for (const name of MODULES_OFF) out[name] = '0';
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { buildPreviewEnv, PREVIEW_DB, CARRIED, POLLERS_OFF, MODULES_OFF };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/proxy.js — forwards one request to the running preview and
|
|
4
|
+
// stamps every page that comes back with the "You are viewing Bongos X" banner
|
|
5
|
+
// (task 1004300, ADR 0349).
|
|
6
|
+
//
|
|
7
|
+
// TRANSPARENT ON PURPOSE. The Host header is kept (the hall is routed on it), the method,
|
|
8
|
+
// path, query and body go through as they came, and a response that is not a page — a JSON
|
|
9
|
+
// answer, a script, an event stream — is piped straight back without being read, so a
|
|
10
|
+
// server-sent-events connection stays open and unbuffered. Only a text/html answer is held
|
|
11
|
+
// long enough to place the banner after its opening body tag; the preview is asked for an
|
|
12
|
+
// uncompressed body so there is nothing to inflate. A version whose pages are older than
|
|
13
|
+
// any banner code still shows it, because the banner is added HERE, never by the preview.
|
|
14
|
+
|
|
15
|
+
const http = require('node:http');
|
|
16
|
+
|
|
17
|
+
// Hop-by-hop headers are meant for one connection, not for the next.
|
|
18
|
+
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
|
|
19
|
+
|
|
20
|
+
const COOKIE_NAME = 'bongos_preview';
|
|
21
|
+
|
|
22
|
+
// The cookie that steers a browser into the preview is ours and stays with us: the preview
|
|
23
|
+
// has no use for it, and a preview page must not be able to read what routed it there.
|
|
24
|
+
function withoutPreviewCookie(cookieHeader) {
|
|
25
|
+
return String(cookieHeader || '')
|
|
26
|
+
.split(';')
|
|
27
|
+
.map((s) => s.trim())
|
|
28
|
+
.filter((s) => s && !s.startsWith(`${COOKIE_NAME}=`))
|
|
29
|
+
.join('; ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function escapeHtml(s) {
|
|
33
|
+
return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The banner markup: which version this is, which one the hall runs, and the way out. */
|
|
37
|
+
function bannerHtml({ viewing, running, exitHref }) {
|
|
38
|
+
return '<div id="bongos-preview-banner" role="status" style="position:sticky;top:0;z-index:2147483647;'
|
|
39
|
+
+ 'display:flex;gap:12px;align-items:center;justify-content:center;flex-wrap:wrap;padding:8px 16px;'
|
|
40
|
+
+ 'background:#1f2933;color:#fff;font:14px/1.4 system-ui,sans-serif;">'
|
|
41
|
+
+ `<span>You are viewing Bongos ${escapeHtml(viewing)} — this hall runs ${escapeHtml(running)}. Changes here do not reach the live hall.</span>`
|
|
42
|
+
+ `<a href="${escapeHtml(exitHref)}" style="color:#fff;text-decoration:underline;font-weight:600;">Back to the live hall</a>`
|
|
43
|
+
+ '</div>';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Place the banner after the opening body tag. A document with no body tag is left alone. */
|
|
47
|
+
function injectBanner(html, banner) {
|
|
48
|
+
const m = /<body\b[^>]*>/i.exec(html);
|
|
49
|
+
if (!m) return html;
|
|
50
|
+
const at = m.index + m[0].length;
|
|
51
|
+
return html.slice(0, at) + banner + html.slice(at);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} o
|
|
56
|
+
* @param {()=>number} o.port the preview's loopback port
|
|
57
|
+
* @param {()=>{viewing:string,running:string}} o.banner what the banner should say now
|
|
58
|
+
* @param {string} o.exitHref
|
|
59
|
+
* @param {object} [o.httpImpl] node:http, swapped in tests
|
|
60
|
+
* @returns {(req, res) => void}
|
|
61
|
+
*/
|
|
62
|
+
function createProxy({ port, banner, exitHref, httpImpl = http } = {}) {
|
|
63
|
+
return function forward(req, res) {
|
|
64
|
+
const headers = {};
|
|
65
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
66
|
+
if (HOP.has(k) || k === 'accept-encoding') continue;
|
|
67
|
+
headers[k] = v;
|
|
68
|
+
}
|
|
69
|
+
headers['accept-encoding'] = 'identity';
|
|
70
|
+
const cookie = withoutPreviewCookie(req.headers.cookie);
|
|
71
|
+
if (cookie) headers.cookie = cookie; else delete headers.cookie;
|
|
72
|
+
|
|
73
|
+
const up = httpImpl.request(
|
|
74
|
+
{ host: '127.0.0.1', port: port(), method: req.method, path: req.originalUrl || req.url, headers },
|
|
75
|
+
(upRes) => {
|
|
76
|
+
const out = {};
|
|
77
|
+
for (const [k, v] of Object.entries(upRes.headers)) if (!HOP.has(k)) out[k] = v;
|
|
78
|
+
const isPage = /^text\/html\b/i.test(String(upRes.headers['content-type'] || ''));
|
|
79
|
+
if (!isPage) {
|
|
80
|
+
res.writeHead(upRes.statusCode, out);
|
|
81
|
+
upRes.pipe(res);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const chunks = [];
|
|
85
|
+
upRes.on('data', (c) => chunks.push(c));
|
|
86
|
+
upRes.on('end', () => {
|
|
87
|
+
const html = injectBanner(Buffer.concat(chunks).toString('utf8'), bannerHtml({ ...banner(), exitHref }));
|
|
88
|
+
delete out['content-length'];
|
|
89
|
+
delete out.etag;
|
|
90
|
+
out['cache-control'] = 'no-store';
|
|
91
|
+
res.writeHead(upRes.statusCode, out);
|
|
92
|
+
res.end(html);
|
|
93
|
+
});
|
|
94
|
+
upRes.on('error', () => res.destroy());
|
|
95
|
+
},
|
|
96
|
+
);
|
|
97
|
+
up.on('error', () => {
|
|
98
|
+
if (res.headersSent) { res.destroy(); return; }
|
|
99
|
+
res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' });
|
|
100
|
+
res.end('The preview is not answering. Go back to the live hall and start it again.');
|
|
101
|
+
});
|
|
102
|
+
res.on('close', () => { if (!res.writableFinished) up.destroy(); });
|
|
103
|
+
req.pipe(up);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { createProxy, injectBanner, bannerHtml, withoutPreviewCookie, COOKIE_NAME };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/npm-release/preview/runtime.js — the real-world wiring of the preview supervisor
|
|
4
|
+
// (task 1004300, ADR 0349): actual child processes, an actual health probe, an idle timer.
|
|
5
|
+
// Everything the supervisor takes as a function is bound here to the operating system, so
|
|
6
|
+
// supervisor.js itself stays free of it and the tests never need to come near this file.
|
|
7
|
+
//
|
|
8
|
+
// NOT EXERCISED BY THE UNIT TESTS, by design: it spawns npm, pg_dump and node. It is small
|
|
9
|
+
// enough to read whole, and the droplet run recorded in the task's part B is what proves it.
|
|
10
|
+
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const http = require('node:http');
|
|
14
|
+
const { spawn } = require('node:child_process');
|
|
15
|
+
const { createSupervisor, DEFAULT_PORT } = require('./supervisor');
|
|
16
|
+
|
|
17
|
+
const REAP_EVERY_MS = 60 * 1000;
|
|
18
|
+
|
|
19
|
+
// The only programs a preview may run, by the name commands.js gives them. Each entry
|
|
20
|
+
// spawns a LITERAL program name, so what runs is answerable by reading this table (the
|
|
21
|
+
// exec-path guard, task 1003371, requires exactly that); a command not in it is refused.
|
|
22
|
+
const PROGRAMS = Object.freeze({
|
|
23
|
+
npm: (args, o) => spawn('npm', args, o),
|
|
24
|
+
dropdb: (args, o) => spawn('dropdb', args, o),
|
|
25
|
+
createdb: (args, o) => spawn('createdb', args, o),
|
|
26
|
+
pg_dump: (args, o) => spawn('pg_dump', args, o),
|
|
27
|
+
pg_restore: (args, o) => spawn('pg_restore', args, o),
|
|
28
|
+
bash: (args, o) => spawn('bash', args, o),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/** Run one command to the end, keeping the tail of its error output for the failure message. */
|
|
32
|
+
function runCommand({ cmd, args, env, cwd }) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let stderr = '';
|
|
35
|
+
let child;
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(PROGRAMS, cmd)) {
|
|
37
|
+
resolve({ code: -1, stderr: 'not a program a preview may run: ' + cmd });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
child = PROGRAMS[cmd](args, { env: env || process.env, cwd, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
resolve({ code: -1, stderr: e.message });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-2000); });
|
|
47
|
+
child.on('error', (e) => resolve({ code: -1, stderr: e.message }));
|
|
48
|
+
child.on('close', (code) => resolve({ code: code === null ? -1 : code, stderr }));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Does something answer /healthz on the loopback port? */
|
|
53
|
+
function probeHealth(port) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
|
|
56
|
+
res.resume();
|
|
57
|
+
resolve(res.statusCode === 200);
|
|
58
|
+
});
|
|
59
|
+
req.on('error', () => resolve(false));
|
|
60
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build the production supervisor: the port from NPM_RELEASE_PREVIEW_PORT (default 3190,
|
|
66
|
+
* loopback only), installs cached under ~/.cache/bongos-preview, and a timer that stops
|
|
67
|
+
* an idle preview and a hook that stops it when this process does.
|
|
68
|
+
*/
|
|
69
|
+
function createRuntime({ liveDb, log, env = process.env } = {}) {
|
|
70
|
+
const port = Number(env.NPM_RELEASE_PREVIEW_PORT) || DEFAULT_PORT;
|
|
71
|
+
const supervisor = createSupervisor({
|
|
72
|
+
run: runCommand, spawn, probe: probeHealth, liveDb, log, port, env,
|
|
73
|
+
cacheRoot: path.join(os.homedir(), '.cache', 'bongos-preview'),
|
|
74
|
+
});
|
|
75
|
+
const timer = setInterval(() => {
|
|
76
|
+
supervisor.reapIdle().catch((e) => log.warn(`preview idle check failed: ${e && e.message}`));
|
|
77
|
+
}, REAP_EVERY_MS);
|
|
78
|
+
timer.unref();
|
|
79
|
+
process.once('exit', () => { supervisor.stop().catch(() => {}); });
|
|
80
|
+
return supervisor;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { createRuntime, runCommand, probeHealth };
|