@bongos/core 1.19.101 → 1.19.103

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.
@@ -13,6 +13,7 @@
13
13
  const express = require('express');
14
14
  const api = require('../../../src/module-api');
15
15
  const pi = require('../platform-identity');
16
+ const projectStats = require('../project-stats');
16
17
 
17
18
  const { pool, requireBuilder, requirePermission, validateOrRespond, parseId, corsPublicGet } = api;
18
19
 
@@ -100,7 +101,26 @@ module.exports = function projectsRoutes() {
100
101
  // rank: public — a curated public showcase; no auth by design.
101
102
  router.get('/projects/featured', async (_req, res) => {
102
103
  try {
103
- res.json({ projects: await pi.listFeaturedProjects({ pool }) });
104
+ const projects = await pi.listFeaturedProjects({ pool });
105
+ // Builders + tags come from each project's OWN public API, because the
106
+ // apex catalog does not know them and the membership tables would report
107
+ // zero for every hand-wired project (task 1002771; see project-stats.js).
108
+ // Enrichment is best-effort by construction: a project that is down gets
109
+ // nulls, which the table renders as an em dash. It must never fail the
110
+ // listing — a broken stats fetch cannot take the catalog down with it.
111
+ let stats = {};
112
+ try {
113
+ stats = await projectStats.getProjectStats(projects.map((p) => p.origin));
114
+ } catch (err) {
115
+ console.error('[platform-identity] project stats enrichment failed (non-fatal):', err.message);
116
+ }
117
+ res.json({
118
+ projects: projects.map((p) => ({
119
+ ...p,
120
+ builders: stats[p.origin]?.builders ?? null,
121
+ tags: stats[p.origin]?.tags ?? null,
122
+ })),
123
+ });
104
124
  } catch (err) {
105
125
  console.error('[platform-identity] GET /projects/featured', err);
106
126
  res.fail('explore_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.101",
3
+ "version": "1.19.103",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.101",
9
+ "version": "1.19.103",
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.19.101",
3
+ "version": "1.19.103",
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",
@@ -540,6 +540,10 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
540
540
  box-shadow:0 0 0 3px color-mix(in srgb,var(--acc) 22%,transparent);}
541
541
  .tbl .ptitle{font-family:var(--font-display);font-weight:700;font-size:15px;color:var(--ink);}
542
542
  .tbl .ptag{font-size:12.5px;color:var(--ink-faint);font-weight:600;}
543
+ .tbl .num{font-family:var(--font-mono);font-weight:500;}
544
+ .tbl .tags{display:flex;gap:6px;flex-wrap:wrap;}
545
+ .tbl .tags span{font-family:var(--font-mono);font-size:10.5px;padding:2px 8px;border-radius:999px;
546
+ border:1px solid var(--line);color:var(--ink-faint);white-space:nowrap;}
543
547
  .tbl .pill{display:inline-block;font-family:var(--font-mono);font-size:11px;padding:3px 10px;
544
548
  border-radius:999px;background:var(--accent-soft);color:var(--accent-deep);}
545
549
  :root[data-mode="dark"] .tbl .pill{color:var(--sun-lit);}
@@ -553,6 +557,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
553
557
  .tbl .colStatus{width:1%;white-space:nowrap;}
554
558
  .tbl .colGo{width:1%;white-space:nowrap;padding-left:8px;}
555
559
  .tbl .colName{width:auto;}
560
+ @media (max-width:780px){.tbl .colTags{display:none;}}
556
561
 
557
562
  /* ── the project card ──
558
563
  An overlay INSIDE the map, not a destination: opening a project must not cost
@@ -182,6 +182,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
182
182
  <thead>
183
183
  <tr>
184
184
  <th class="colName" data-sort="name" aria-sort="ascending">Project</th>
185
+ <th class="right" data-sort="builders">Builders</th>
186
+ <th class="colTags">Runs</th>
185
187
  <th class="right colStatus">Status</th>
186
188
  <th class="colGo"></th>
187
189
  </tr>
@@ -196,6 +198,8 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
196
198
  <td class="colName"><span class="pname"><span class="pdot" style="--acc:var(--accent)"></span>
197
199
  <span><span class="ptitle" data-brand-product-inline>Cloud Bongos</span><span id="selfPill"></span><br>
198
200
  <span class="ptag">this platform</span></span></span></td>
201
+ <td class="right num" id="selfBuilders">—</td>
202
+ <td class="colTags" id="selfTags">—</td>
199
203
  <td class="right colStatus"><span class="chip calm"><span class="cdot"></span>—</span></td>
200
204
  <td class="colGo"><a class="btn small ghost" href="#" data-self-open>Open →</a></td>
201
205
  </tr>
@@ -568,6 +572,18 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
568
572
  if (!me) return 'none';
569
573
  return me.rank === 'archon' ? 'owner' : 'builder';
570
574
  }
575
+ /* A tag is a module the project RUNS beyond the core set — a fact it declares
576
+ about itself, not a guess about what it is. Capped so one heavily-moduled
577
+ project cannot blow the column open; the overflow is counted, never hidden
578
+ silently. null = we could not ask (em dash); [] = asked, runs nothing beyond
579
+ core, which is a real answer and still reads as a dash in the cell. */
580
+ var TAG_CAP = 3;
581
+ function tagsHtml(tags) {
582
+ if (!Array.isArray(tags) || !tags.length) return '—';
583
+ var shown = tags.slice(0, TAG_CAP).map(function (t) { return '<span>' + esc(t) + '</span>'; });
584
+ if (tags.length > TAG_CAP) shown.push('<span>+' + (tags.length - TAG_CAP) + '</span>');
585
+ return '<span class="tags">' + shown.join('') + '</span>';
586
+ }
571
587
  function matchesMapQuery(p, q) {
572
588
  if (!q) return true;
573
589
  return (String(p.name || '') + ' ' + String(p.tagline || '')).toLowerCase().indexOf(q) !== -1;
@@ -622,6 +638,13 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
622
638
  accentOf(p) + '"></span>' +
623
639
  '<span><span class="ptitle">' + esc(p.name) + '</span>' + pill + '<br>' +
624
640
  '<span class="ptag">' + esc(p.tagline || '') + '</span></span></span></td>' +
641
+ /* Builders and Runs come from the project's OWN public API, enriched
642
+ server-side (task 1002771). null is unknown — the project was
643
+ unreachable — and an em dash says exactly that. 0 is a real answer and
644
+ renders as 0, because "nobody has joined yet" is a different fact from
645
+ "we could not ask". */
646
+ '<td class="right num">' + (p.builders == null ? '—' : esc(String(p.builders))) + '</td>' +
647
+ '<td class="colTags">' + tagsHtml(p.tags) + '</td>' +
625
648
  /* liveness on a catalog row is NOT probed by this surface. A chip that
626
649
  said "live" here would claim more than anything verified (C6); the
627
650
  verification overlay on owned orbs is its own task. */
@@ -641,6 +664,15 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
641
664
  });
642
665
  list.sort(function (x, y) {
643
666
  if (mapSort.key === 'name') return String(x.name || '').localeCompare(String(y.name || '')) * mapSort.dir;
667
+ /* unknown (null) sorts to the BOTTOM in both directions — it is not a low
668
+ number, and letting it ride the comparator would rank "we could not ask"
669
+ above a project that honestly has one builder */
670
+ if (mapSort.key === 'builders') {
671
+ if (x.builders == null && y.builders == null) return 0;
672
+ if (x.builders == null) return 1;
673
+ if (y.builders == null) return -1;
674
+ return (x.builders - y.builders) * mapSort.dir;
675
+ }
644
676
  return 0;
645
677
  });
646
678
  renderOrbs(list);
@@ -759,6 +791,25 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
759
791
  if (ev.key === 'Escape') closeCard();
760
792
  });
761
793
 
794
+ /* The platform's own row is not in the catalog (it is synthesized — task
795
+ 1002760), so the server-side enrichment never sees it. It can answer for
796
+ itself though: this is its own origin, so one same-origin call gets the same
797
+ number by the same rule the server applies to everyone else — real builders,
798
+ machine accounts excluded. Its Runs cell stays a dash honestly: every module
799
+ this instance enables is a CORE module, so it declares nothing beyond the
800
+ baseline and there is no tag to show. */
801
+ function loadSelfStats() {
802
+ return fetch(API + '/public/leaderboard', { credentials: 'same-origin' })
803
+ .then(function (res) { return res.ok ? res.json() : null; })
804
+ .then(function (d) {
805
+ var rows = (d && d.leaderboard) || [];
806
+ if (!rows.length) return;
807
+ var real = rows.filter(function (b) { return !/-system$/.test(String(b.github_login || '')); });
808
+ $('selfBuilders').textContent = String(real.length);
809
+ })
810
+ .catch(function () { /* unreachable → the dash already in the markup stands */ });
811
+ }
812
+
762
813
  function loadCatalog() {
763
814
  fetch(API + '/projects/featured', { credentials: 'same-origin' })
764
815
  .then(function (res) { if (!res.ok) throw new Error('catalog'); return res.json(); })
@@ -1467,7 +1518,9 @@ try{var _m=localStorage.getItem('cb-color-mode');if(_m==='light'||_m==='dark')do
1467
1518
  })
1468
1519
  /* the catalog is PUBLIC, so it loads either way — the map is the view a
1469
1520
  signed-out stranger lands on and it must not depend on identity */
1470
- .then(loadCatalog, loadCatalog);
1521
+ .then(loadCatalog, loadCatalog)
1522
+ /* the platform's own count is public too, so it loads either way */
1523
+ .then(loadSelfStats, loadSelfStats);
1471
1524
  })();
1472
1525
  </script>
1473
1526
  </body>
@@ -112,6 +112,7 @@ function resolveRunParams({
112
112
  env = process.env,
113
113
  userInfo = os.userInfo,
114
114
  argv = [],
115
+ mode = null,
115
116
  } = {}) {
116
117
  const facts = loadBrandingFacts({ instanceRoot, coreRoot, f });
117
118
  const modules = readJsonSafe(path.join(instanceRoot, 'config', 'modules.json'), f) || {};
@@ -119,6 +120,9 @@ function resolveRunParams({
119
120
 
120
121
  const portOverride = argFrom(argv, '--port');
121
122
  const dbOverride = argFrom(argv, '--db');
123
+ // 'server' = a real host (systemd-supervised, no auto-login, outward health
124
+ // probe); 'local' = the ADR 0149 laptop path. Same six steps either way.
125
+ const runMode = mode || (argv.includes('--server') ? 'server' : 'local');
122
126
 
123
127
  const port = portOverride ? parsePort(`http://x:${portOverride}`, parsePort(facts.publicOrigin)) : parsePort(facts.publicOrigin);
124
128
  const dbName = deriveDbName({ brandingDbName: facts.brandingDbName, instanceRoot, override: dbOverride });
@@ -126,16 +130,19 @@ function resolveRunParams({
126
130
  let pgUser = env.PGUSER;
127
131
  if (!pgUser) { try { pgUser = userInfo().username; } catch { pgUser = undefined; } }
128
132
 
133
+ const publicOrigin = facts.publicOrigin.replace(/\/+$/, '');
134
+
129
135
  return {
130
136
  instanceRoot,
131
137
  coreRoot,
138
+ mode: runMode,
132
139
  port,
133
- host: HOST,
140
+ host: deriveBindHost({ publicOrigin, mode: runMode, override: argFrom(argv, '--bind') || env.HOST }),
134
141
  dbName,
135
142
  pgHost: env.PGHOST || 'localhost',
136
143
  pgUser,
137
144
  pgPassword: env.PGPASSWORD || undefined,
138
- publicOrigin: facts.publicOrigin.replace(/\/+$/, ''),
145
+ publicOrigin,
139
146
  firstAdmin: facts.firstAdmin,
140
147
  envPrefix: facts.envPrefix,
141
148
  configDir: facts.configDir,
@@ -257,7 +264,7 @@ function stop(params, deps = {}) {
257
264
  // /healthz (needs no DB); authConfigured comes from /api/gds/healthz.
258
265
  async function health(params, deps = {}) {
259
266
  const fetchImpl = deps.fetchImpl || fetch;
260
- const base = `http://${HOST}:${params.port}`;
267
+ const base = `http://${probeHost(params)}:${params.port}`;
261
268
  const timeout = deps.healthTimeoutMs || 4000;
262
269
  let up = false;
263
270
  try {
@@ -300,10 +307,209 @@ function depsInstallPlan({ instanceRoot, f = fs } = {}) {
300
307
  : { needed: true, cmd: 'install', args: ['install'] };
301
308
  }
302
309
 
310
+ // ===================== SERVER MODE (task 1002398, ADR 0162) =====================
311
+ // The SAME boot path — deps -> Postgres -> createdb -> migrate -> env -> server ->
312
+ // health — retargeted at a real host. Four decisions differ from local, and every
313
+ // one of them is a pure function here so tests/dev_lib.mjs can prove it without a
314
+ // host: where to bind, how to start Postgres, how the DB role gets bootstrapped
315
+ // under peer auth, and what supervises the process.
316
+
317
+ // `bongos serve stop` arrives as argv ['--server','stop'] once bin/bongos.js
318
+ // prefixes the mode flag, and `--port 3006 status` puts a VALUE where the old
319
+ // `argv[0]` scan looked for the subcommand. Resolve the subcommand by skipping
320
+ // flags and the values they consume, so flag order never changes the verb.
321
+ const VALUE_FLAGS = new Set(['--port', '--db', '--bind', '--user', '--env-file']);
322
+
323
+ function parseSub(argv = [], fallback = 'up') {
324
+ for (let i = 0; i < argv.length; i++) {
325
+ const a = argv[i];
326
+ if (a === '--') break;
327
+ if (a.startsWith('-')) { if (VALUE_FLAGS.has(a)) i++; continue; }
328
+ return a;
329
+ }
330
+ return fallback;
331
+ }
332
+
333
+ // Where the server binds. Local is always loopback. On a real host the right
334
+ // answer is derivable from the origin the instance is CONFIGURED for, so an
335
+ // operator never has to reason about it:
336
+ // https://… -> a TLS-terminating proxy fronts us -> 127.0.0.1
337
+ // http://localhost:… -> a local-shaped origin on a host -> 127.0.0.1
338
+ // http://<public>:… -> nothing in front of us yet -> 0.0.0.0
339
+ // An explicit --bind (or HOST in the environment) still wins over all of it.
340
+ function deriveBindHost({ publicOrigin, mode = 'local', override } = {}) {
341
+ if (override) return String(override);
342
+ if (mode !== 'server') return HOST;
343
+ let u;
344
+ try { u = new URL(String(publicOrigin)); } catch { return HOST; }
345
+ if (u.protocol === 'https:') return HOST;
346
+ if (u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1') return HOST;
347
+ return '0.0.0.0';
348
+ }
349
+
350
+ // The address to PROBE for health. Binding 0.0.0.0 still answers on loopback;
351
+ // binding one specific address does not, so probe whatever we actually bound.
352
+ function probeHost(params = {}) {
353
+ const h = params.host || HOST;
354
+ return (h === '0.0.0.0' || h === '::') ? HOST : h;
355
+ }
356
+
357
+ function serviceName(params) { return `bongos-${sanitizeDbName(params.dbName) || 'instance'}`; }
358
+ function unitPath(params, { systemdDir = '/etc/systemd/system' } = {}) {
359
+ return path.posix.join(systemdDir, `${serviceName(params)}.service`);
360
+ }
361
+ // Secrets (PGPASSWORD, GitHub OAuth creds) belong in a root-owned 0600 file the
362
+ // unit references, NEVER in Environment= lines — `systemctl show` and the journal
363
+ // expose those to any user on the box.
364
+ function envFilePath(params, { etcBase = '/etc/bongos' } = {}) {
365
+ return path.posix.join(etcBase, `${serviceName(params)}.env`);
366
+ }
367
+
368
+ // The unit's Environment= set: ONLY the keys the boot path resolves. Deliberately
369
+ // not buildEnv() — that spreads the caller's whole environment, which under
370
+ // systemd would bake a builder's shell (tokens included) into a root-owned file.
371
+ function unitEnvironment(params) {
372
+ const pairs = [
373
+ ['PORT', String(params.port)],
374
+ ['HOST', params.host],
375
+ ['PGHOST', params.pgHost],
376
+ ['PGDATABASE', params.dbName],
377
+ ['NODE_ENV', 'production'],
378
+ ];
379
+ if (params.pgUser) pairs.push(['PGUSER', params.pgUser]);
380
+ if (params.gdsOnly) pairs.push(['MEDUSA_GDS_ONLY', '1']);
381
+ pairs.push([`${params.envPrefix}_INSTANCE_ROOT`, params.instanceRoot]);
382
+ return pairs;
383
+ }
384
+
385
+ // systemd accepts Environment="K=v with spaces"; quote every value so a path with
386
+ // a space (or a quote) can't split the assignment.
387
+ function quoteUnitValue(v) { return `"${String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; }
388
+
389
+ function renderSystemdUnit(params, { nodePath = process.execPath, user, group, envFile } = {}) {
390
+ const env = unitEnvironment(params).map(([k, v]) => `Environment=${quoteUnitValue(`${k}=${v}`)}`);
391
+ const lines = [
392
+ '[Unit]',
393
+ `Description=Cloud Bongos instance ${params.dbName} (bongos serve)`,
394
+ 'Wants=network-online.target',
395
+ // postgresql.service may not exist (managed/remote DB) — After= on an absent
396
+ // unit is a no-op, so this is safe either way and orders us correctly when it
397
+ // IS local. No Requires=: a remote-DB host must still boot.
398
+ 'After=network-online.target postgresql.service',
399
+ '',
400
+ '[Service]',
401
+ 'Type=simple',
402
+ `WorkingDirectory=${params.instanceRoot}`,
403
+ `ExecStart=${nodePath} ${serverEntry(params)}`,
404
+ ...env,
405
+ `EnvironmentFile=-${envFile || envFilePath(params)}`,
406
+ // Survive a crash AND a reboot — the whole point of moving off the pid file.
407
+ 'Restart=always',
408
+ 'RestartSec=3',
409
+ 'NoNewPrivileges=yes',
410
+ 'StandardOutput=journal',
411
+ 'StandardError=journal',
412
+ ];
413
+ if (user) lines.push(`User=${user}`);
414
+ if (group) lines.push(`Group=${group}`);
415
+ lines.push('', '[Install]', 'WantedBy=multi-user.target', '');
416
+ return lines.join('\n');
417
+ }
418
+
419
+ // Can this host supervise with systemd, and do we need sudo to install the unit?
420
+ function systemdPlan({ platform = process.platform, has = () => false, isRoot = false } = {}) {
421
+ if (platform !== 'linux') return { available: false, reason: `systemd is linux-only (this host is ${platform})` };
422
+ if (!has('systemctl')) return { available: false, reason: 'systemctl is not on PATH' };
423
+ if (isRoot) return { available: true, sudo: false };
424
+ if (!has('sudo')) return { available: false, reason: 'installing a unit needs root, and sudo is not on PATH' };
425
+ return { available: true, sudo: true };
426
+ }
427
+
428
+ // Prefix a step with non-interactive sudo when we are not already root. `sudo -n`
429
+ // never prompts: on a host without passwordless sudo it fails fast with a clear
430
+ // error instead of hanging a deploy on a hidden password prompt.
431
+ function withSudo(step, { isRoot = false, sudo = true } = {}) {
432
+ if (isRoot || !sudo) return step;
433
+ return { ...step, cmd: 'sudo', args: ['-n', step.cmd, ...step.args] };
434
+ }
435
+
436
+ // Ordered candidates for "start Postgres", best-first for the platform. dev.js
437
+ // tries each until pg_isready passes. Homebrew keeps its own branch there (the
438
+ // 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 } = {}) {
440
+ const out = [];
441
+ if (platform === 'darwin' && has('brew')) out.push({ cmd: 'brew', args: ['services', 'start'], needsFormula: true, label: 'Homebrew' });
442
+ if (platform === 'linux') {
443
+ if (has('systemctl')) out.push(withSudo({ cmd: 'systemctl', args: ['start', 'postgresql'], label: 'systemd' }, { isRoot }));
444
+ else if (has('service')) out.push(withSudo({ cmd: 'service', args: ['postgresql', 'start'], label: 'service' }, { isRoot }));
445
+ }
446
+ if (has('pg_ctl') && env.PGDATA) out.push({ cmd: 'pg_ctl', args: ['start', '-w', '-D', env.PGDATA], label: 'pg_ctl' });
447
+ return out;
448
+ }
449
+
450
+ // Postgres identifiers are case-folded unless quoted, and a role name reaches the
451
+ // server as SQL text — quote it properly and refuse anything that isn't a plain
452
+ // identifier rather than interpolating a surprise into a CREATE ROLE.
453
+ const SAFE_IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
454
+ function quoteIdent(s) { return `"${String(s).replace(/"/g, '""')}"`; }
455
+
456
+ // The fresh-Debian wall: `apt-get install postgresql` creates exactly ONE role,
457
+ // `postgres`, reachable only by peer auth as the postgres OS user. A deploy user
458
+ // running `createdb` gets 'role "deploy" does not exist' — which reads like a
459
+ // broken install and is really just an unbootstrapped cluster. These two steps,
460
+ // run as the postgres superuser, are the whole fix.
461
+ function pgBootstrapPlan({ pgUser, dbName, isRoot = false, has = () => false } = {}) {
462
+ if (!pgUser || !SAFE_IDENT.test(pgUser)) return { ok: false, reason: `refusing to bootstrap an unsafe role name: ${pgUser || '(none)'}` };
463
+ if (!dbName || !SAFE_IDENT.test(dbName)) return { ok: false, reason: `refusing to bootstrap an unsafe database name: ${dbName || '(none)'}` };
464
+ // Reach the postgres superuser the way this host allows: `sudo -u postgres` for
465
+ // a normal deploy user (and for root when sudo exists), else `su postgres -c`
466
+ // when we are already root on a minimal image with no sudo installed.
467
+ let asPostgres;
468
+ if (has('sudo')) asPostgres = (cmd, args, label) => ({ cmd: 'sudo', args: ['-n', '-u', 'postgres', cmd, ...args], label });
469
+ else if (isRoot) asPostgres = (cmd, args, label) => ({ cmd: 'su', args: ['postgres', '-c', shellJoin([cmd, ...args])], label });
470
+ else return { ok: false, reason: 'bootstrapping the role needs the postgres superuser (root or sudo)' };
471
+
472
+ return {
473
+ ok: true,
474
+ steps: [
475
+ { ...asPostgres('psql', ['-v', 'ON_ERROR_STOP=1', '-tAc',
476
+ `SELECT 1 FROM pg_roles WHERE rolname='${pgUser}'`], 'check role'), id: 'check-role' },
477
+ { ...asPostgres('psql', ['-v', 'ON_ERROR_STOP=1', '-c',
478
+ `CREATE ROLE ${quoteIdent(pgUser)} LOGIN CREATEDB`], 'create role'), id: 'create-role' },
479
+ { ...asPostgres('createdb', ['-O', pgUser, dbName], 'create database'), id: 'create-db' },
480
+ ],
481
+ };
482
+ }
483
+
484
+ // Quote an argv into a single `su -c` string. Only ever fed argv this module
485
+ // built from validated identifiers, but quote anyway — `su -c` is a shell.
486
+ function shellJoin(argv) {
487
+ return argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${String(a).replace(/'/g, `'\\''`)}'`)).join(' ');
488
+ }
489
+
490
+ // Health as seen from OUTSIDE — does the origin the instance advertises actually
491
+ // answer? On a server that also exercises DNS, the proxy, and TLS, which the
492
+ // loopback probe cannot see. Advisory: DNS may legitimately not be wired yet.
493
+ async function publicHealth(params, deps = {}) {
494
+ const fetchImpl = deps.fetchImpl || fetch;
495
+ const timeout = deps.healthTimeoutMs || 6000;
496
+ try {
497
+ const r = await fetchImpl(`${params.publicOrigin}/healthz`, { signal: AbortSignal.timeout(timeout) });
498
+ return { reachable: r.status < 500, status: r.status };
499
+ } catch (e) {
500
+ return { reachable: false, error: e && e.message ? e.message : String(e) };
501
+ }
502
+ }
503
+
303
504
  module.exports = {
304
505
  HOST, DEFAULT_PORT, CORE_ROOT,
305
506
  readJsonSafe, loadBrandingFacts, parsePort, sanitizeDbName, deriveDbName,
306
507
  resolveRunParams, buildEnv, authWillWork, depsInstallPlan,
307
508
  slug, pidPath, logPath, readPid, pidAlive, isRunning, serverEntry,
308
509
  startDetached, stop, health, argFrom,
510
+ // server mode (task 1002398). withSudo/quoteIdent/shellJoin stay private —
511
+ // they are implementation details of the plans above, not a callable surface.
512
+ parseSub, deriveBindHost, probeHost, serviceName, unitPath, envFilePath,
513
+ unitEnvironment, renderSystemdUnit, systemdPlan, pgStartPlan,
514
+ pgBootstrapPlan, publicHealth,
309
515
  };