@bongos/core 1.19.101 → 1.19.102

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.
@@ -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
  };
@@ -57,22 +57,36 @@ function sleepSync(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)),
57
57
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
58
58
 
59
59
  function usage() {
60
- console.log(`bongos dev — run THIS instance locally, in one command.
60
+ console.log(`bongos dev — run THIS instance, in one command.
61
61
 
62
62
  Usage:
63
63
  bongos dev [--foreground] [--no-login] [--skip-migrate] [--port <n>] [--db <name>]
64
64
  bongos dev stop Stop the local server this launched
65
65
  bongos dev status Show whether it's running, its URL, and sign-in state
66
66
 
67
+ bongos serve Same boot path, on a REAL HOST (= bongos dev --server)
68
+ bongos serve stop Stop + disable the service
69
+ bongos serve status Service state, health, and whether the public origin answers
70
+
67
71
  What it does (idempotent — safe to re-run):
68
- 1. Make sure Postgres is running (starts it via Homebrew if it isn't)
69
- 2. Create this instance's database if it doesn't exist
70
- 3. Build/refresh its tables (migrations)
71
- 4. Start the server on the port in config/branding.json
72
- 5. Sign you in to this instance (unless --no-login)
72
+ 1. Install dependencies if this checkout has none
73
+ 2. Make sure Postgres is running (Homebrew on a Mac, systemd/service on a host)
74
+ 3. Create this instance's database if it doesn't exist
75
+ 4. Build/refresh its tables (migrations)
76
+ 5. Start the server on the port in config/branding.json
77
+ 6. Sign you in (local only — a host prints the command instead)
78
+
79
+ What --server changes:
80
+ • supervision — a systemd unit (enabled at boot, restarts on failure) instead of
81
+ a pid file, falling back to the background process where systemd is absent
82
+ • bind address — 127.0.0.1 behind a TLS proxy (https origin), else 0.0.0.0
83
+ • it bootstraps the Postgres login role on a fresh cluster (peer auth)
84
+ • it checks the PUBLIC origin, not just loopback, and never opens a browser
85
+ Extra flags: --bind <addr>, --user <unix-user> (who the service runs as).
73
86
 
74
87
  Needs Postgres + Node >= 22 installed. macOS/Linux (uses bash for migrations;
75
- on Windows use WSL or the Docker path in docs/recipes/self-host.md).`);
88
+ on Windows use WSL or the Docker path in docs/recipes/self-host.md).
89
+ Server walkthrough: docs/recipes/server-bring-up.md`);
76
90
  }
77
91
 
78
92
  // psql/createdb take -h/-U only when we have them; libpq fills the rest.
@@ -86,21 +100,31 @@ function pgReady(params) {
86
100
  return run('pg_isready', [...pgArgs(params), '-q']).status === 0;
87
101
  }
88
102
 
89
- // Best-effort: start a Homebrew-managed Postgres, then poll until it accepts
90
- // connections. Returns true if ready, false if we couldn't bring it up.
103
+ // Am I root? (Only meaningful on POSIX; Windows has no getuid.)
104
+ function isRoot() { return typeof process.getuid === 'function' && process.getuid() === 0; }
105
+
106
+ // Best-effort: start whatever service manager owns Postgres on this platform
107
+ // (Homebrew on a Mac, systemd/service on a Linux host, pg_ctl with PGDATA
108
+ // anywhere), then poll until it accepts connections. The candidate list + sudo
109
+ // prefixing are decided by dev-lib.pgStartPlan; this just executes them in order.
91
110
  function ensurePostgres(params) {
92
111
  if (pgReady(params)) { ok('Postgres is running'); return true; }
93
112
  info('Postgres is not running — trying to start it…');
94
- if (has('brew')) {
95
- const formulae = run('brew', ['list', '--formula']).stdout
96
- .split(/\s+/).filter((x) => /^postgresql(@\d+)?$/.test(x))
97
- .sort().reverse();
98
- const target = formulae[0] || 'postgresql@16';
99
- run('brew', ['services', 'start', target], { stdio: 'ignore' });
100
- } else if (has('pg_ctl') && process.env.PGDATA) {
101
- run('pg_ctl', ['start', '-w', '-D', process.env.PGDATA], { stdio: 'ignore' });
113
+ for (const step of dev.pgStartPlan({ has, isRoot: isRoot() })) {
114
+ let args = step.args;
115
+ if (step.needsFormula) {
116
+ // Homebrew's formula name has to be discovered at runtime (postgresql@16,
117
+ // @17, …) — the only start candidate that isn't statically known.
118
+ const formulae = run('brew', ['list', '--formula']).stdout
119
+ .split(/\s+/).filter((x) => /^postgresql(@\d+)?$/.test(x))
120
+ .sort().reverse();
121
+ args = [...step.args, formulae[0] || 'postgresql@16'];
122
+ }
123
+ run(step.cmd, args, { stdio: 'ignore' });
124
+ if (pgReady(params)) { ok(`Postgres started (${step.label})`); return true; }
102
125
  }
103
- // Poll for readiness (~15s). pg_isready is cheap; pause between probes.
126
+ // Poll for readiness (~15s) — a service manager returns before the cluster is
127
+ // actually accepting connections. pg_isready is cheap; pause between probes.
104
128
  for (let i = 0; i < 15; i++) {
105
129
  if (pgReady(params)) { ok('Postgres started'); return true; }
106
130
  sleepSync(1000);
@@ -114,12 +138,44 @@ function dbExists(params) {
114
138
  return r.status === 0 && r.stdout.trim() === '1';
115
139
  }
116
140
 
141
+ // A fresh `apt-get install postgresql` cluster has exactly one role — `postgres`,
142
+ // peer-auth only — so a deploy user's createdb fails with 'role "deploy" does not
143
+ // exist'. That reads like a broken install and is really an unbootstrapped
144
+ // cluster. Create the login role (and the DB it owns) as the postgres superuser.
145
+ function bootstrapRole(params) {
146
+ const plan = dev.pgBootstrapPlan({ pgUser: params.pgUser, dbName: params.dbName, isRoot: isRoot(), has });
147
+ if (!plan.ok) { warn(plan.reason); return false; }
148
+ info(`bootstrapping the Postgres role "${params.pgUser}" (first run on this host)…`);
149
+ const byId = Object.fromEntries(plan.steps.map((s) => [s.id, s]));
150
+
151
+ const check = byId['check-role'];
152
+ const already = run(check.cmd, check.args).stdout.trim() === '1';
153
+ if (!already) {
154
+ const mk = byId['create-role'];
155
+ const r = run(mk.cmd, mk.args);
156
+ if (r.status !== 0) { fail(`could not create the Postgres role "${params.pgUser}": ${tail(r.stderr, 2)}`); return false; }
157
+ ok(`created Postgres role "${params.pgUser}"`);
158
+ }
159
+ const mkdb = byId['create-db'];
160
+ const r = run(mkdb.cmd, mkdb.args);
161
+ if (r.status === 0 || /already exists/i.test(r.stderr)) { ok(`database "${params.dbName}" ready`); return true; }
162
+ fail(`could not create database "${params.dbName}" as the postgres superuser: ${tail(r.stderr, 2)}`);
163
+ return false;
164
+ }
165
+
166
+ // Does this createdb failure mean "the cluster was never bootstrapped for me"
167
+ // (fixable, see bootstrapRole) rather than a genuine error?
168
+ function needsRoleBootstrap(stderr) {
169
+ return /role .* does not exist|Peer authentication failed|password authentication failed|no password supplied/i.test(String(stderr || ''));
170
+ }
171
+
117
172
  function createDb(params) {
118
173
  if (dbExists(params)) { ok(`database "${params.dbName}" exists`); return true; }
119
174
  const r = run('createdb', [...pgArgs(params), params.dbName]);
120
175
  if (r.status === 0) { ok(`created database "${params.dbName}"`); return true; }
121
176
  // A race or prior partial run may have created it; treat "already exists" as fine.
122
177
  if (/already exists/i.test(r.stderr)) { ok(`database "${params.dbName}" exists`); return true; }
178
+ if (needsRoleBootstrap(r.stderr) && process.platform === 'linux') return bootstrapRole(params);
123
179
  fail(`could not create database "${params.dbName}": ${tail(r.stderr, 2) || r.error}`);
124
180
  return false;
125
181
  }
@@ -152,6 +208,15 @@ function printOAuthGuidance(params) {
152
208
  console.log(` Authorization callback URL: ${cb}`);
153
209
  console.log(' Then tick "Enable Device Flow" and Save (a separate save).');
154
210
  console.log(' 2. Copy the Client ID; generate a client secret.');
211
+ if (params.mode === 'server') {
212
+ // On a host the server runs as a service user with its own (often empty) home,
213
+ // so the env file the unit already reads is the reliable place for creds.
214
+ console.log(` 3. Put both in ${dev.envFilePath(params)} (0600, root-owned):`);
215
+ console.log(` ${params.envPrefix}_GITHUB_CLIENT_ID=…`);
216
+ console.log(` ${params.envPrefix}_GITHUB_CLIENT_SECRET=…`);
217
+ console.log(` 4. systemctl restart ${dev.serviceName(params)}`);
218
+ return;
219
+ }
155
220
  console.log(` 3. Save both into ${credsPath} as:`);
156
221
  console.log(' { "client_id": "…", "client_secret": "…" }');
157
222
  console.log(' 4. Re-run `bongos dev`.');
@@ -182,9 +247,124 @@ function loginNow(params) {
182
247
  return r.status === 0;
183
248
  }
184
249
 
250
+ // ---- server mode: systemd supervision (task 1002398) ---------------------------
251
+ // On a laptop a detached pid-file process is the right amount of machinery. On a
252
+ // real host it is not: it dies on reboot, it has no restart policy, and its logs
253
+ // are a file in /tmp. So server mode installs a systemd unit and lets the host's
254
+ // own supervisor own the process — the pid-file path stays as the fallback for a
255
+ // host without systemd (a container, a BSD, a non-root operator).
256
+
257
+ function sudoWorks() { return run('sudo', ['-n', 'true']).status === 0; }
258
+
259
+ // Write a root-owned file, directly when we are root and via `sudo tee` when not.
260
+ function writeRootFile(target, contents, useSudo, mode = null) {
261
+ const dir = path.posix.dirname(target);
262
+ if (!useSudo) {
263
+ try {
264
+ fs.mkdirSync(dir, { recursive: true });
265
+ fs.writeFileSync(target, contents, mode ? { mode } : undefined);
266
+ return { ok: true };
267
+ } catch (e) { return { ok: false, err: e.message }; }
268
+ }
269
+ const mk = run('sudo', ['-n', 'mkdir', '-p', dir]);
270
+ if (mk.status !== 0) return { ok: false, err: tail(mk.stderr, 2) };
271
+ const w = run('sudo', ['-n', 'tee', target], { input: contents, stdio: ['pipe', 'ignore', 'pipe'] });
272
+ if (w.status !== 0) return { ok: false, err: tail(w.stderr, 2) };
273
+ if (mode) run('sudo', ['-n', 'chmod', mode.toString(8).padStart(4, '0'), target]);
274
+ return { ok: true };
275
+ }
276
+
277
+ function rootFileExists(target, useSudo) {
278
+ if (!useSudo) return fs.existsSync(target);
279
+ return run('sudo', ['-n', 'test', '-f', target]).status === 0;
280
+ }
281
+
282
+ // The secrets sidecar the unit reads. Created ONCE, 0600, never overwritten — a
283
+ // re-run must not clobber credentials an operator pasted in. Values stay out of
284
+ // Environment= lines on purpose: `systemctl show` leaks those to every user.
285
+ function ensureEnvFile(params, useSudo) {
286
+ const target = dev.envFilePath(params);
287
+ if (rootFileExists(target, useSudo)) return { path: target, created: false };
288
+ const body = [
289
+ `# ${dev.serviceName(params)} — secrets for this instance. Root-owned, 0600.`,
290
+ '# Referenced by the systemd unit as EnvironmentFile=- (optional: absent is fine).',
291
+ '# Fill in what applies, then: systemctl restart ' + dev.serviceName(params),
292
+ '',
293
+ '# Postgres password, if the role is not trusted/peer-authenticated:',
294
+ '#PGPASSWORD=',
295
+ '',
296
+ '# GitHub OAuth app for sign-in (see `bongos serve` output):',
297
+ `#${params.envPrefix}_GITHUB_CLIENT_ID=`,
298
+ `#${params.envPrefix}_GITHUB_CLIENT_SECRET=`,
299
+ '',
300
+ ].join('\n');
301
+ const w = writeRootFile(target, body, useSudo, 0o600);
302
+ return { path: target, created: w.ok, err: w.err };
303
+ }
304
+
305
+ const sc = (useSudo, args) => (useSudo ? run('sudo', ['-n', 'systemctl', ...args]) : run('systemctl', args));
306
+
307
+ // Install (or refresh) the unit and bring the service up. Idempotent: rewriting
308
+ // an identical unit is a no-op, and `restart` covers both "not yet running" and
309
+ // "running the previous unit".
310
+ function installUnit(params, plan) {
311
+ const service = dev.serviceName(params);
312
+ const target = dev.unitPath(params);
313
+ const envFile = ensureEnvFile(params, plan.sudo);
314
+ if (envFile.created) ok(`wrote ${envFile.path} (0600 — put secrets here)`);
315
+ else if (envFile.err) warn(`could not create ${envFile.path}: ${envFile.err}`);
316
+
317
+ const unit = dev.renderSystemdUnit(params, {
318
+ nodePath: process.execPath,
319
+ user: arg('--user') || (isRoot() ? null : currentUser()),
320
+ envFile: envFile.path,
321
+ });
322
+ const w = writeRootFile(target, unit, plan.sudo);
323
+ if (!w.ok) { fail(`could not write ${target}: ${w.err}`); return { ok: false }; }
324
+ ok(`installed ${target}`);
325
+
326
+ if (sc(plan.sudo, ['daemon-reload']).status !== 0) { fail('systemctl daemon-reload failed'); return { ok: false }; }
327
+ // enable = survive reboot; restart = pick up this unit whether or not it ran.
328
+ sc(plan.sudo, ['enable', service]);
329
+ const r = sc(plan.sudo, ['restart', service]);
330
+ if (r.status !== 0) {
331
+ fail(`systemctl restart ${service} failed:`);
332
+ console.error(tail(r.stderr, 6));
333
+ console.error(` journalctl -u ${service} -n 50 --no-pager`);
334
+ return { ok: false };
335
+ }
336
+ return { ok: true, service, unitPath: target, envFile: envFile.path };
337
+ }
338
+
339
+ function currentUser() {
340
+ try { return require('node:os').userInfo().username; } catch { return null; }
341
+ }
342
+
343
+ // `systemctl is-active` exits non-zero when inactive, so read stdout, not status.
344
+ function unitActive(params, useSudo) {
345
+ return sc(useSudo, ['is-active', dev.serviceName(params)]).stdout.trim() === 'active';
346
+ }
347
+
185
348
  // ---- subcommands ---------------------------------------------------------------
186
349
 
187
350
  function cmdStop(params) {
351
+ // Server mode: if a unit owns this instance, stopping the pid-file process
352
+ // would be a no-op (or worse, systemd would restart it) — stop the unit, and
353
+ // disable it so "stopped" survives a reboot. `bongos serve` re-enables it.
354
+ if (params.mode === 'server') {
355
+ const plan = dev.systemdPlan({ has, isRoot: isRoot() });
356
+ if (plan.available) {
357
+ const useSudo = plan.sudo && sudoWorks();
358
+ const service = dev.serviceName(params);
359
+ if (rootFileExists(dev.unitPath(params), useSudo)) {
360
+ const r = sc(useSudo, ['stop', service]);
361
+ sc(useSudo, ['disable', service]);
362
+ if (r.status === 0) { ok(`stopped + disabled ${service}`); return 0; }
363
+ fail(`could not stop ${service}: ${tail(r.stderr, 2)}`);
364
+ return 1;
365
+ }
366
+ }
367
+ }
188
368
  const res = dev.stop(params);
189
369
  if (res.wasRunning) ok(`stopped the local ${params.dbName} server (pid ${res.pid})`);
190
370
  else console.log(` (no running ${params.dbName} server to stop)`);
@@ -192,10 +372,31 @@ function cmdStop(params) {
192
372
  }
193
373
 
194
374
  async function cmdStatus(params) {
195
- const running = dev.isRunning(params);
375
+ const server = params.mode === 'server';
196
376
  console.log(`instance: ${path.basename(params.instanceRoot)} (${params.publicOrigin})`);
197
377
  console.log(`database: ${params.dbName} on ${params.pgHost}`);
198
- if (!running) { console.log('server: not running — start it with `bongos dev`'); return 0; }
378
+ console.log(`bind: ${params.host}:${params.port}`);
379
+
380
+ if (server) {
381
+ const plan = dev.systemdPlan({ has, isRoot: isRoot() });
382
+ const useSudo = plan.available && plan.sudo && sudoWorks();
383
+ if (plan.available && rootFileExists(dev.unitPath(params), useSudo)) {
384
+ const active = unitActive(params, useSudo);
385
+ console.log(`service: ${dev.serviceName(params)} — ${active ? 'active' : 'inactive'} (${dev.unitPath(params)})`);
386
+ const h = await dev.health(params);
387
+ console.log(`health: ${h.up ? 'OK' : 'unreachable on the bind address'}`);
388
+ console.log(`sign-in: ${h.authConfigured ? 'configured' : 'NOT configured (see `bongos serve`)'}`);
389
+ const pub = await dev.publicHealth(params);
390
+ console.log(`public: ${pub.reachable ? `reachable (${params.publicOrigin})` : `NOT reachable — ${pub.error || `HTTP ${pub.status}`}`}`);
391
+ console.log(`logs: journalctl -u ${dev.serviceName(params)} -f`);
392
+ return 0;
393
+ }
394
+ console.log('service: no systemd unit installed — start it with `bongos serve`');
395
+ // fall through: a fallback pid-managed server may still be up.
396
+ }
397
+
398
+ const running = dev.isRunning(params);
399
+ if (!running) { console.log(`server: not running — start it with \`bongos ${server ? 'serve' : 'dev'}\``); return 0; }
199
400
  const h = await dev.health(params);
200
401
  console.log(`server: running (pid ${dev.readPid(params)}), health ${h.up ? 'OK' : 'starting/unreachable'}`);
201
402
  console.log(`sign-in: ${h.authConfigured ? 'configured' : 'NOT configured (see `bongos dev`)'}`);
@@ -203,6 +404,71 @@ async function cmdStatus(params) {
203
404
  return 0;
204
405
  }
205
406
 
407
+ // The tail of a server-mode `up`: supervise, wait for health, report the outward
408
+ // view. Never auto-signs-in (a host has no browser) — it prints the command.
409
+ async function cmdServeStart(params) {
410
+ const plan = dev.systemdPlan({ has, isRoot: isRoot() });
411
+ let managed = null;
412
+
413
+ if (plan.available) {
414
+ const useSudo = plan.sudo && sudoWorks();
415
+ if (plan.sudo && !useSudo) {
416
+ warn('sudo needs a password here, so I cannot install a systemd unit non-interactively.');
417
+ console.log(' Either run `sudo bongos serve`, or grant passwordless sudo, and I will supervise with systemd.');
418
+ } else {
419
+ managed = installUnit(params, { ...plan, sudo: useSudo });
420
+ if (!managed.ok) return 1;
421
+ ok(`${managed.service} started (enabled at boot, restarts on failure)`);
422
+ }
423
+ } else {
424
+ warn(`no systemd here (${plan.reason}) — falling back to a managed background process.`);
425
+ console.log(' It will NOT survive a reboot. Add it to this host’s own supervisor for production.');
426
+ }
427
+
428
+ if (!managed) {
429
+ const started = dev.startDetached(params);
430
+ if (started.already) ok(`server already running (pid ${started.pid})`);
431
+ else ok(`server running (pid ${started.pid}), logs: ${started.log}`);
432
+ }
433
+
434
+ const h = await waitHealthy(params);
435
+ if (!h.up) {
436
+ fail(`the server did not become healthy on ${params.host}:${params.port}. Recent log:`);
437
+ if (managed) console.error(run('journalctl', ['-u', managed.service, '-n', '20', '--no-pager']).stdout || ` journalctl -u ${managed.service} -n 50 --no-pager`);
438
+ else { try { console.error(tail(fs.readFileSync(dev.logPath(params), 'utf8'), 12)); } catch { /* no log */ } }
439
+ return 1;
440
+ }
441
+ ok(`healthy on ${params.host}:${params.port}`);
442
+
443
+ // The loopback probe proves the app. This proves the PATH TO IT — DNS, the
444
+ // proxy, TLS — which is the part that actually differs on a real host.
445
+ const pub = await dev.publicHealth(params);
446
+ blank();
447
+ if (pub.reachable) {
448
+ ok(`${params.publicOrigin} is answering`);
449
+ } else {
450
+ warn(`${params.publicOrigin} is not answering yet (${pub.error || `HTTP ${pub.status}`}).`);
451
+ console.log(' The app is up on this host — what is missing is in front of it:');
452
+ if (params.host === '127.0.0.1') console.log(` • a reverse proxy forwarding ${params.publicOrigin} → 127.0.0.1:${params.port} (TLS terminates there)`);
453
+ else console.log(` • DNS for ${new URL(params.publicOrigin).hostname} pointing at this host, and port ${params.port} open in the firewall`);
454
+ console.log(' • see docs/recipes/server-bring-up.md');
455
+ }
456
+
457
+ blank();
458
+ console.log(` → ${params.publicOrigin}`);
459
+ if (managed) {
460
+ console.log(` status: bongos serve status logs: journalctl -u ${managed.service} -f`);
461
+ console.log(` stop: bongos serve stop secrets: ${managed.envFile}`);
462
+ } else {
463
+ console.log(` status: bongos serve status stop: bongos serve stop`);
464
+ }
465
+ blank();
466
+
467
+ if (!dev.authWillWork()) { printOAuthGuidance(params); return 0; }
468
+ console.log(` Sign in from your own machine: bongos login ${params.publicOrigin}`);
469
+ return 0;
470
+ }
471
+
206
472
  async function cmdUp(params, { foreground, noLogin, skipMigrate }) {
207
473
  info(`Standing up ${path.basename(params.instanceRoot)} on ${params.publicOrigin}`);
208
474
 
@@ -270,6 +536,9 @@ async function cmdUp(params, { foreground, noLogin, skipMigrate }) {
270
536
  return r.status == null ? 1 : r.status;
271
537
  }
272
538
 
539
+ // Server mode: hand the process to the host's own supervisor.
540
+ if (params.mode === 'server') return cmdServeStart(params);
541
+
273
542
  // Default: managed background server.
274
543
  const started = dev.startDetached(params);
275
544
  if (started.already) ok(`server already running (pid ${started.pid})`);
@@ -302,7 +571,9 @@ async function main() {
302
571
  if (hasFlag('--help') || hasFlag('-h') || argv[0] === 'help') { usage(); return 0; }
303
572
 
304
573
  const params = dev.resolveRunParams({ instanceRoot: process.cwd(), argv });
305
- const sub = argv[0] && !argv[0].startsWith('-') ? argv[0] : 'up';
574
+ // Flag-order-independent: `bongos serve stop` reaches us as ['--server','stop'],
575
+ // and `--port 3006 status` puts a flag VALUE where a naive argv[0] scan looked.
576
+ const sub = dev.parseSub(argv);
306
577
 
307
578
  if (sub === 'stop') return cmdStop(params);
308
579
  if (sub === 'status') return cmdStatus(params);