pgserve 2.3.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/bin/pgserve-wrapper.cjs +9 -4
  2. package/bin/postgres-server.js +170 -631
  3. package/config/logrotate.d/pgserve +47 -0
  4. package/config/pgaudit.conf +31 -0
  5. package/package.json +3 -2
  6. package/scripts/audit-redaction-lint.js +349 -0
  7. package/scripts/test-npx.sh +32 -10
  8. package/src/audit/audit.js +134 -0
  9. package/src/cli-install.cjs +340 -100
  10. package/src/commands/uninstall.js +241 -0
  11. package/src/commands/verify.js +360 -0
  12. package/src/cosign/cache-token.js +328 -0
  13. package/src/cosign/schema.js +97 -0
  14. package/src/cosign/trust-list.js +81 -0
  15. package/src/cosign/verify-binary.js +277 -0
  16. package/src/index.js +11 -44
  17. package/src/lib/admin-json.js +202 -0
  18. package/src/lib/pm2-args.js +119 -0
  19. package/src/lib/runtime-json.js +181 -0
  20. package/src/lib/socket-dir.js +69 -0
  21. package/src/postgres.js +64 -5
  22. package/src/upgrade/index.js +5 -0
  23. package/src/upgrade/steps/cosign-meta-migration.js +123 -0
  24. package/src/admin-client.js +0 -223
  25. package/src/audit.js +0 -168
  26. package/src/cluster.js +0 -654
  27. package/src/control-db.js +0 -330
  28. package/src/daemon-control.js +0 -468
  29. package/src/daemon-shared.js +0 -18
  30. package/src/daemon-tcp.js +0 -297
  31. package/src/daemon.js +0 -709
  32. package/src/dashboard.js +0 -217
  33. package/src/fingerprint.js +0 -479
  34. package/src/gc.js +0 -351
  35. package/src/pg-wire.js +0 -869
  36. package/src/protocol.js +0 -389
  37. package/src/restore.js +0 -574
  38. package/src/router.js +0 -546
  39. package/src/sdk.js +0 -137
  40. package/src/stats-collector.js +0 -453
  41. package/src/stats-dashboard.js +0 -401
  42. package/src/sync.js +0 -335
  43. package/src/tenancy.js +0 -75
  44. package/src/tokens.js +0 -102
@@ -27,8 +27,42 @@ const fs = require('node:fs');
27
27
  const os = require('node:os');
28
28
  const path = require('node:path');
29
29
 
30
- const PM2_PROCESS_NAME = 'pgserve';
31
- const DEFAULT_PORT = 8432;
30
+ // pgserve singleton (v2.4): the cohort-shared admin-json + socket-dir
31
+ // helpers live in `src/lib/*.js` as ESM modules (project convention: new
32
+ // modules ship as .js / ESM). cli-install.cjs runs under node — which
33
+ // cannot synchronously `require()` ESM — so we cache the dynamic-import
34
+ // promise once at module load and await it from async install paths.
35
+ const _adminJsonModuleP = import('./lib/admin-json.js');
36
+ const _socketDirModuleP = import('./lib/socket-dir.js');
37
+ const _runtimeJsonModuleP = import('./lib/runtime-json.js');
38
+
39
+ async function loadCohortModules() {
40
+ const [adminJson, socketDirMod, runtimeJson] = await Promise.all([
41
+ _adminJsonModuleP,
42
+ _socketDirModuleP,
43
+ _runtimeJsonModuleP,
44
+ ]);
45
+ return { adminJson, socketDirMod, runtimeJson };
46
+ }
47
+
48
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
49
+ //
50
+ // The pm2 entry name moves from `pgserve` to `autopg-server` so the canonical
51
+ // two-process layout matches the cohort design (`autopg-server` + `autopg-ui`).
52
+ // Tier B operators install a systemd-user unit that ALSO claims the
53
+ // `autopg-server` lifecycle role — `~/.autopg/admin.json` records which
54
+ // supervisor owns the host and `pgserve install` refuses to register pm2 over
55
+ // an existing Tier B record.
56
+ //
57
+ // Default postgres port moves from 8432 (the old bun-proxy listener) to 5432
58
+ // (the postgres standard, since the postmaster now binds TCP directly).
59
+ const PM2_PROCESS_NAME = 'autopg-server';
60
+ // Legacy entry name (pre-v2.4). Self-healing migration in Group 6 will
61
+ // `pm2 delete pgserve` after registering `autopg-server`; we surface the
62
+ // constant here so cleanup tooling and tests can reference it without
63
+ // hardcoding strings.
64
+ const LEGACY_PM2_PROCESS_NAME = 'pgserve';
65
+ const DEFAULT_PORT = 5432;
32
66
 
33
67
  // Console UI is auto-supervised under pm2 alongside the daemon since v2.2.3.
34
68
  // The bundled SPA (console/dist/) is served on this port; operator-facing
@@ -136,6 +170,131 @@ function readConfig() {
136
170
  }
137
171
  }
138
172
 
173
+ // cutover G19: discovery layer used by `autopg port / url / status`.
174
+ //
175
+ // Order of precedence (most-authoritative first):
176
+ // 1. `<socketDir>/runtime.json` — written by the live postmaster at greet
177
+ // time, removed on graceful shutdown. Carries the *current* port + pid
178
+ // for an actually-running daemon.
179
+ // 2. `~/.autopg/admin.json` — supervisor record written at install time.
180
+ // Survives postmaster restarts; doesn't reflect runtime state.
181
+ // 3. `~/.autopg/config.json` — legacy pre-G19 install record. Final
182
+ // fallback so older installs that haven't been re-installed under v2.4
183
+ // still discover cleanly.
184
+ //
185
+ // All readers swallow errors — discovery must never throw on a missing or
186
+ // truncated file. Synchronous on purpose: `dispatch()` for status/url/port
187
+ // is sync and the wrapper handles only `Promise OR number` return types.
188
+ function readRuntimeJsonSync(socketDir) {
189
+ if (typeof socketDir !== 'string' || socketDir.length === 0) return null;
190
+ const file = path.join(socketDir, 'runtime.json');
191
+ let raw;
192
+ try {
193
+ raw = fs.readFileSync(file, 'utf8');
194
+ } catch {
195
+ return null;
196
+ }
197
+ try {
198
+ const parsed = JSON.parse(raw);
199
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ function readAdminJsonSync() {
206
+ const file = path.join(getConfigDir(), ADMIN_FILE_NAME);
207
+ let raw;
208
+ try {
209
+ raw = fs.readFileSync(file, 'utf8');
210
+ } catch {
211
+ return null;
212
+ }
213
+ try {
214
+ const parsed = JSON.parse(raw);
215
+ return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : null;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ function resolveCanonicalSocketDir() {
222
+ // Mirror src/lib/socket-dir.js#resolveSocketDir — pure function, no fs
223
+ // touch. Inlined here so the sync discovery layer doesn't need a top-
224
+ // level await on the ESM module.
225
+ const xdg = process.env.XDG_RUNTIME_DIR;
226
+ const base = xdg && xdg.length > 0 ? xdg : '/tmp';
227
+ return path.join(base, 'pgserve');
228
+ }
229
+
230
+ function isLivePid(pid) {
231
+ if (!Number.isInteger(pid) || pid < 1) return false;
232
+ try {
233
+ process.kill(pid, 0);
234
+ return true;
235
+ } catch (err) {
236
+ return err && err.code === 'EPERM';
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Compose a discovery view from runtime.json (preferred), admin.json
242
+ * (fallback), and config.json (legacy fallback). Returns:
243
+ * {
244
+ * runtime: { socketDir, port, pid, autopgPid, schemaVersion } | null,
245
+ * admin: { supervisor, socketDir, port, installedAt, ... } | null,
246
+ * config: { port, dataDir, registeredAt } | null,
247
+ * // composed view — best effort merge for callers that just want
248
+ * // "where do I connect right now?":
249
+ * socketDir: <string|null>,
250
+ * port: <number|null>,
251
+ * liveAutopg: <boolean> // true when runtime.json names a live pid
252
+ * }
253
+ */
254
+ function readDiscovery() {
255
+ const config = readConfig();
256
+ const admin = readAdminJsonSync();
257
+ // Prefer the socket dir the supervisor recorded at install time — that's
258
+ // the path operators configured. Only fall back to the canonical resolver
259
+ // when the install record is missing (fresh-host case).
260
+ const socketDir = (admin && typeof admin.socketDir === 'string' && admin.socketDir.length > 0)
261
+ ? admin.socketDir
262
+ : resolveCanonicalSocketDir();
263
+ const runtime = readRuntimeJsonSync(socketDir);
264
+
265
+ // PR #80 P2 fix: previous logic treated ANY parsed runtime.json as
266
+ // authoritative — a malformed-but-JSON file (no port, no socketDir) would
267
+ // hide later admin.json / config fallbacks because composedPort stayed
268
+ // null while the precedence chain stopped early. Validate that runtime
269
+ // actually carries a usable port + socketDir before treating it as live.
270
+ // Mirrors the admin / config branches' Number.isInteger guard.
271
+ let composedSocketDir = null;
272
+ let composedPort = null;
273
+ const runtimeUsable = runtime
274
+ && Number.isInteger(runtime.port)
275
+ && typeof runtime.socketDir === 'string'
276
+ && runtime.socketDir.length > 0;
277
+ if (runtimeUsable) {
278
+ composedSocketDir = runtime.socketDir;
279
+ composedPort = runtime.port;
280
+ } else if (admin && Number.isInteger(admin.port)) {
281
+ composedSocketDir = admin.socketDir ?? socketDir;
282
+ composedPort = admin.port;
283
+ } else if (config && Number.isInteger(config.port)) {
284
+ composedPort = config.port;
285
+ composedSocketDir = socketDir;
286
+ }
287
+
288
+ return {
289
+ runtime,
290
+ admin,
291
+ config,
292
+ socketDir: composedSocketDir,
293
+ port: composedPort,
294
+ liveAutopg: !!(runtime && isLivePid(runtime.autopgPid)),
295
+ };
296
+ }
297
+
139
298
  function writeConfig(config) {
140
299
  const dir = getConfigDir();
141
300
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
@@ -200,7 +359,7 @@ function getEffectiveSupervision() {
200
359
  }
201
360
  }
202
361
 
203
- function buildPm2StartArgs({ scriptPath, port, dataDir }) {
362
+ function buildPm2StartArgs({ scriptPath, port, dataDir, socketDir }) {
204
363
  const logs = {
205
364
  out: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-out.log`),
206
365
  error: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-error.log`),
@@ -215,19 +374,11 @@ function buildPm2StartArgs({ scriptPath, port, dataDir }) {
215
374
  'none',
216
375
  '--max-restarts',
217
376
  String(supervision.maxRestarts),
218
- // NOTE: pm2 ≥ 6.0 dropped `--min-uptime` from the CLI surface — passing
219
- // it produces `error: unknown option --min-uptime` and aborts the
220
- // install. The flag still works inside an ecosystem file, but per the
221
- // canonical-pm2-supervision wish we keep `pgserve install` as a pure
222
- // CLI flow (no extra files for operators to manage). The trade-off is
223
- // that `--max-restarts` now counts every restart (rapid or not) rather
224
- // than only sub-`min_uptime` ones; the budget of 50 above is sized
225
- // accordingly.
377
+ // pm2 ≥ 6.0 dropped `--min-uptime` from the CLI surface — passing it
378
+ // aborts the install. Restart budget (50) is sized to absorb a few
379
+ // long-uptime crashes without burning through.
226
380
  '--restart-delay',
227
381
  String(supervision.restartDelayMs),
228
- // Exponential backoff between successive failures: starts at 100ms,
229
- // doubles each crash, ramps to ~60s. Avoids hammering pm2 + the host
230
- // when the underlying issue is persistent.
231
382
  '--exp-backoff-restart-delay',
232
383
  String(supervision.expBackoffRestartDelayMs),
233
384
  '--max-memory-restart',
@@ -241,28 +392,19 @@ function buildPm2StartArgs({ scriptPath, port, dataDir }) {
241
392
  '--error',
242
393
  logs.error,
243
394
  '--',
244
- // Foreground multi-tenant mode (`pgserve [options]`), NOT daemon mode.
245
- //
246
- // Daemon mode binds a unix control socket and requires libpq peers to
247
- // authenticate via a fingerprint+token handshake (`pgserve daemon
248
- // issue-token`). Downstream services that connect with a plain
249
- // `postgres://` URL (omni, genie, anything that doesn't speak the
250
- // fingerprint protocol) cannot reach a daemon-mode listener. We also
251
- // observed live: `pgserve install` was passing `--port` to the daemon
252
- // parser, which only accepts `--data | --ram | --log | --no-provision
253
- // | --listen | --pgvector` — every install attempt crashed with
254
- // `Unknown daemon option: --port` and pm2 burned its restart budget.
255
- //
256
- // Foreground mode (the default `pgserve [options]` invocation in
257
- // postgres-server.js) accepts `--port`, auto-provisions databases on
258
- // first connect, runs the cluster on multi-core hosts, and binds TCP
259
- // on `127.0.0.1:<port>` with no auth dance — exactly what canonical
260
- // pgserve consumers expect. Pass the same flags pgserve already
261
- // documents in its own `pgserve --help` output.
395
+ // pgserve singleton (v2.4): pm2 supervises the postmaster directly via
396
+ // the `pgserve postmaster` subcommand — no router, no bun proxy, no
397
+ // daemon control socket. Postgres binds the canonical Unix socket
398
+ // under <socketDir> AND TCP <port> natively. Operators connect via
399
+ // psql -h $XDG_RUNTIME_DIR/pgserve (Unix socket, no -p)
400
+ // psql -h 127.0.0.1 -p 5432 (canonical TCP)
401
+ 'postmaster',
262
402
  '--port',
263
403
  String(port),
264
404
  '--data',
265
405
  dataDir,
406
+ '--socket-dir',
407
+ socketDir,
266
408
  '--log',
267
409
  'warn',
268
410
  ];
@@ -384,21 +526,9 @@ function cmdInstallUi(ctx, options = {}) {
384
526
  return 0;
385
527
  }
386
528
 
387
- function cmdUninstallUi() {
388
- if (!pm2IsAvailable()) return 0;
389
- // Always attempt the delete `pm2 delete <name>` is idempotent and
390
- // exits non-zero on a missing process, which is fine to swallow. This
391
- // is more robust than a pre-existence check against `pm2 jlist`, which
392
- // can lag the actual process state (or, in test stubs, return a
393
- // partial registry).
394
- const result = spawnSync('pm2', ['delete', UI_PM2_PROCESS_NAME], {
395
- stdio: ['ignore', 'pipe', 'pipe'],
396
- });
397
- if (result.status === 0) {
398
- ok(`UI uninstalled (pm2 process "${UI_PM2_PROCESS_NAME}" removed)`);
399
- }
400
- return 0;
401
- }
529
+ // `cmdUninstall` + `cmdUninstallUi` migrated to `src/commands/uninstall.js`
530
+ // as part of canonical-pgserve-pm2-supervision Group 1. The dispatch
531
+ // `case 'uninstall'` above dynamically imports the new module.
402
532
 
403
533
  // ─── Admin password (Basic Auth for `autopg ui`) ─────────────────────────
404
534
 
@@ -429,12 +559,19 @@ function writeAdminFile({ password, rotated = false }) {
429
559
  const hash = hashAdminPassword(password, salt);
430
560
  const file = getAdminFilePath();
431
561
  const now = new Date().toISOString();
562
+ // pgserve singleton (v2.4): admin.json is shared with the supervisor
563
+ // record (`{ supervisor, socketDir, port, installedAt }`) written by
564
+ // `src/lib/admin-json.js`. Merge with any existing fields so the scrypt
565
+ // Basic-Auth scheme can never wipe the supervisor metadata (and vice
566
+ // versa).
567
+ const existing = readAdminFile() || {};
432
568
  const payload = {
569
+ ...existing,
433
570
  scheme: 'scrypt',
434
571
  params: SCRYPT_PARAMS,
435
572
  salt: salt.toString('base64'),
436
573
  hash: hash.toString('base64'),
437
- createdAt: rotated ? readAdminFile()?.createdAt ?? now : now,
574
+ createdAt: rotated ? existing.createdAt ?? now : now,
438
575
  rotatedAt: rotated ? now : null,
439
576
  };
440
577
  ensureConfigDir();
@@ -487,9 +624,14 @@ function verifyAdminPassword(candidate) {
487
624
 
488
625
  function ensureAdminPassword({ rotate = false } = {}) {
489
626
  const existing = readAdminFile();
490
- if (existing && !rotate) return null;
627
+ // pgserve singleton (v2.4): admin.json may exist with only supervisor
628
+ // fields (`{ supervisor, socketDir, port, installedAt }`) and no scrypt
629
+ // scheme yet. Treat "no scrypt scheme" as "no password" so the install
630
+ // path still mints one on first run.
631
+ const hasScryptScheme = !!(existing && existing.scheme === 'scrypt');
632
+ if (hasScryptScheme && !rotate) return null;
491
633
  const password = generateAdminPassword();
492
- writeAdminFile({ password, rotated: !!existing });
634
+ writeAdminFile({ password, rotated: !!hasScryptScheme });
493
635
  return password;
494
636
  }
495
637
 
@@ -532,14 +674,41 @@ function cmdAuthDispatch(args) {
532
674
  * `scriptPath` is the path to `bin/postgres-server.js` resolved by the
533
675
  * wrapper before this module is required (avoids re-resolving here).
534
676
  */
535
- function cmdInstall(args, ctx) {
536
- if (!pm2IsAvailable()) {
537
- fail('pm2 not found in PATH. Install with: bun add -g pm2 (or npm i -g pm2)');
677
+ async function cmdInstall(args, ctx) {
678
+ const { adminJson, socketDirMod } = await loadCohortModules();
679
+
680
+ // pgserve singleton (v2.4): refuse to install if a different supervisor
681
+ // (Tier B systemd-user / launchd) already owns the host. The cohort
682
+ // contract guarantees one and only one supervisor records itself in
683
+ // `~/.autopg/admin.json`. `pgserve install` is the Tier A (pm2) entry —
684
+ // it asserts that nothing else has already claimed the host before
685
+ // touching pm2.
686
+ const noPm2 = args.includes('--no-pm2');
687
+ const expectedSupervisor = noPm2 ? 'external' : 'pm2';
688
+ try {
689
+ adminJson.assertSupervisor(expectedSupervisor, { configDir: getConfigDir() });
690
+ } catch (err) {
691
+ fail(err.message);
692
+ }
693
+
694
+ if (!noPm2 && !pm2IsAvailable()) {
695
+ fail('pm2 not found in PATH. Install with: bun add -g pm2 (or npm i -g pm2). Pass --no-pm2 for CI / Tier B-bound hosts.');
538
696
  }
539
697
 
540
698
  const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
541
699
  const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
542
700
 
701
+ // Set up the canonical socket directory before pm2 launches the
702
+ // postmaster. `ensureSocketDir` enforces mode 0700 and probes writability
703
+ // so failure surfaces here — not as a libpq bind error a few seconds
704
+ // into pm2 backoff.
705
+ const socketDir = parseSocketDir(args) ?? socketDirMod.resolveSocketDir();
706
+ try {
707
+ socketDirMod.ensureSocketDir(socketDir);
708
+ } catch (err) {
709
+ fail(err.message);
710
+ }
711
+
543
712
  const noUi = args.includes('--no-ui');
544
713
  const withUi = args.includes('--with-ui');
545
714
  const redeploy = args.includes('--redeploy');
@@ -559,6 +728,22 @@ function cmdInstall(args, ctx) {
559
728
  return 0;
560
729
  }
561
730
 
731
+ // --no-pm2: skip pm2 register entirely. Used by CI fixture provisioning
732
+ // (no sudo, no pm2 ambient state) and by Tier B-bound hosts that will
733
+ // immediately hand off to `autopg service install`. Still record the
734
+ // supervisor + canonical socket dir + port in admin.json so downstream
735
+ // tooling (pgserve doctor, omni install, genie install) can discover
736
+ // where to connect without an active pm2 entry.
737
+ if (noPm2) {
738
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
739
+ writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
740
+ writeSupervisorRecord(adminJson, { supervisor: 'external', socketDir, port });
741
+ ok(`installed (--no-pm2): supervisor=external, socketDir=${socketDir}, port=${port}`);
742
+ ok(`url: postgres://localhost:${port}/postgres`);
743
+ note(`--no-pm2: pm2 register skipped. Start the postmaster manually with \`pgserve postmaster --port ${port} --data ${dataDir} --socket-dir ${socketDir}\` or hand off to systemd-user / launchd.`);
744
+ return 0;
745
+ }
746
+
562
747
  // --redeploy: full reset. Tear down both processes, then proceed with
563
748
  // a fresh install. Equivalent to `autopg uninstall && autopg install`
564
749
  // but in one verb.
@@ -581,6 +766,7 @@ function cmdInstall(args, ctx) {
581
766
  // don't tear down the live process. Operators wanting a port change
582
767
  // should `uninstall` then `install` (or pass --redeploy).
583
768
  writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
769
+ writeSupervisorRecord(adminJson, { supervisor: 'pm2', socketDir, port });
584
770
  if (!noUi) cmdInstallUi(ctx, { uiPort, uiHost });
585
771
  return 0;
586
772
  }
@@ -588,14 +774,15 @@ function cmdInstall(args, ctx) {
588
774
  ensureLogsDir();
589
775
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
590
776
 
591
- const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir });
777
+ const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir, socketDir });
592
778
  const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
593
779
  if (result.status !== 0) {
594
780
  fail(`pm2 start failed (exit ${result.status}). Logs: ${getLogsDir()}/${PM2_PROCESS_NAME}-error.log`);
595
781
  }
596
782
 
597
783
  writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
598
- ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
784
+ writeSupervisorRecord(adminJson, { supervisor: 'pm2', socketDir, port });
785
+ ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (socket: ${socketDir}, data: ${dataDir})`);
599
786
  ok(`url: postgres://localhost:${port}/postgres`);
600
787
 
601
788
  if (noUi) {
@@ -620,43 +807,47 @@ function cmdInstall(args, ctx) {
620
807
  }
621
808
 
622
809
  /**
623
- * `pgserve uninstall`
624
- *
625
- * Removes pgserve from pm2. Leaves the data directory and config file
626
- * intact — operator can `rm -rf ~/.pgserve` after they're satisfied no
627
- * downstream service still depends on the data.
810
+ * Persist the supervisor record into `~/.autopg/admin.json`. Wraps
811
+ * `writeAdminJson` from the cohort-shared module so the install path can
812
+ * pin its own ISO timestamp + log a friendly diagnostic on the
813
+ * supervisor-lock refusal.
628
814
  */
629
- function cmdUninstall() {
630
- // Delete the daemon first — it's the load-bearing process and the order
631
- // of "what existed at uninstall start" is determined by the daemon's
632
- // pm2 registry, not the UI's. UI cleanup follows; soft-fails if absent.
633
- const existing = pm2GetProcess(PM2_PROCESS_NAME);
634
- if (!existing) {
635
- ok(`not registered under pm2 (nothing to uninstall)`);
636
- cmdUninstallUi();
637
- return 0;
638
- }
639
- const result = spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: 'inherit' });
640
- if (result.status !== 0) {
641
- fail(`pm2 delete failed (exit ${result.status})`);
815
+ function writeSupervisorRecord(adminJson, { supervisor, socketDir, port }) {
816
+ try {
817
+ adminJson.writeAdminJson(
818
+ {
819
+ supervisor,
820
+ socketDir,
821
+ port,
822
+ installedAt: new Date().toISOString(),
823
+ },
824
+ { configDir: getConfigDir() },
825
+ );
826
+ } catch (err) {
827
+ // The "Tier B already owns this host" error is the contract failure
828
+ // we surface to operators. Other errors (EACCES, ENOSPC, etc.) just
829
+ // pass through with their stock message.
830
+ fail(err.message);
642
831
  }
643
- ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
644
- cmdUninstallUi();
645
- return 0;
646
832
  }
647
833
 
648
834
  /**
649
835
  * `pgserve status [--json]`
650
836
  *
651
- * Reports both pm2 state and on-disk config. Exits 0 with status info
652
- * regardless of running/stopped operators script around the JSON output.
653
- * Non-zero only when the config is missing entirely (i.e. pgserve was
654
- * never installed).
837
+ * Reports both pm2 state and on-disk discovery (runtime.json admin.json
838
+ * config.json fallback chain). Exits 0 with status info regardless of
839
+ * running/stopped operators script around the JSON output. Non-zero
840
+ * only when nothing was ever installed (no admin.json AND no config.json).
841
+ *
842
+ * Cutover G19: surfaces `runtime` (live socket discovery) and `socketDir`
843
+ * top-level so consumers can pick UDS vs TCP without parsing pm2 jlist.
655
844
  */
656
845
  function cmdStatus(args) {
657
846
  const json = args.includes('--json');
658
- const config = readConfig();
659
- if (!config) {
847
+ const discovery = readDiscovery();
848
+ const { config, admin, runtime } = discovery;
849
+
850
+ if (!config && !admin) {
660
851
  if (json) {
661
852
  process.stdout.write(`${JSON.stringify({ installed: false })}\n`);
662
853
  } else {
@@ -664,24 +855,41 @@ function cmdStatus(args) {
664
855
  }
665
856
  return 1;
666
857
  }
858
+
667
859
  const proc = pm2GetProcess(PM2_PROCESS_NAME);
668
860
  const status = proc?.pm2_env?.status ?? 'stopped';
669
861
  const pid = proc?.pid ?? null;
670
862
  const uptimeMs = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : null;
671
863
  const restarts = proc?.pm2_env?.restart_time ?? 0;
672
864
 
865
+ const port = discovery.port;
866
+ const socketDir = discovery.socketDir;
867
+ const dataDir = config?.dataDir ?? null;
868
+
673
869
  const payload = {
674
870
  installed: true,
675
871
  name: PM2_PROCESS_NAME,
676
872
  status,
677
873
  pid,
678
- port: config.port,
679
- dataDir: config.dataDir,
874
+ port,
875
+ socketDir,
876
+ dataDir,
680
877
  logsDir: getLogsDir(),
681
- url: `postgres://localhost:${config.port}/postgres`,
878
+ url: port ? `postgres://localhost:${port}/postgres` : null,
682
879
  uptimeMs,
683
880
  restarts,
684
- registeredAt: config.registeredAt,
881
+ registeredAt: config?.registeredAt ?? null,
882
+ supervisor: admin?.supervisor ?? null,
883
+ runtime: runtime
884
+ ? {
885
+ socketDir: runtime.socketDir,
886
+ port: runtime.port,
887
+ pid: runtime.pid,
888
+ autopgPid: runtime.autopgPid,
889
+ schemaVersion: runtime.schemaVersion,
890
+ live: discovery.liveAutopg,
891
+ }
892
+ : null,
685
893
  };
686
894
 
687
895
  if (json) {
@@ -690,42 +898,53 @@ function cmdStatus(args) {
690
898
  }
691
899
  process.stdout.write(`name ${payload.name}\n`);
692
900
  process.stdout.write(`status ${payload.status}${payload.pid ? ` (pid ${payload.pid})` : ''}\n`);
693
- process.stdout.write(`port ${payload.port}\n`);
694
- process.stdout.write(`url ${payload.url}\n`);
695
- process.stdout.write(`dataDir ${payload.dataDir}\n`);
901
+ if (payload.supervisor) {
902
+ process.stdout.write(`supervisor ${payload.supervisor}\n`);
903
+ }
904
+ if (payload.port != null) process.stdout.write(`port ${payload.port}\n`);
905
+ if (payload.url) process.stdout.write(`url ${payload.url}\n`);
906
+ if (payload.socketDir) process.stdout.write(`socketDir ${payload.socketDir}\n`);
907
+ if (payload.dataDir) process.stdout.write(`dataDir ${payload.dataDir}\n`);
696
908
  process.stdout.write(`logsDir ${payload.logsDir}\n`);
909
+ if (payload.runtime) {
910
+ process.stdout.write(`runtime pid=${payload.runtime.pid} autopgPid=${payload.runtime.autopgPid} live=${payload.runtime.live}\n`);
911
+ } else {
912
+ process.stdout.write(`runtime (no runtime.json — postmaster down or never started)\n`);
913
+ }
697
914
  if (payload.uptimeMs != null) {
698
915
  const sec = Math.floor(payload.uptimeMs / 1000);
699
916
  process.stdout.write(`uptime ${sec}s\n`);
700
917
  }
701
918
  process.stdout.write(`restarts ${payload.restarts}\n`);
702
- process.stdout.write(`registered ${payload.registeredAt}\n`);
919
+ if (payload.registeredAt) process.stdout.write(`registered ${payload.registeredAt}\n`);
703
920
  return 0;
704
921
  }
705
922
 
706
923
  /**
707
924
  * `pgserve url`
708
925
  *
709
- * Discovery API. Prints the canonical connection string. Downstream
926
+ * Discovery API. Prints the canonical TCP connection string. Downstream
710
927
  * installers (genie install, omni install) call this to learn where to
711
- * connect, instead of hardcoding a port.
928
+ * connect, instead of hardcoding a port. The TCP form is stable across
929
+ * Tier A / Tier B / fingerprint-disabled hosts; UDS callers should
930
+ * resolve `<socketDir>/.s.PGSQL.<port>` from `autopg status --json`.
712
931
  */
713
932
  function cmdUrl() {
714
- const config = readConfig();
715
- if (!config) {
933
+ const discovery = readDiscovery();
934
+ if (discovery.port == null) {
716
935
  fail('not installed (run: pgserve install)');
717
936
  }
718
- process.stdout.write(`postgres://localhost:${config.port}/postgres\n`);
937
+ process.stdout.write(`postgres://localhost:${discovery.port}/postgres\n`);
719
938
  return 0;
720
939
  }
721
940
 
722
- /** `pgserve port` — print the canonical port. */
941
+ /** `pgserve port` — print the canonical port from runtime.json → admin.json → config.json. */
723
942
  function cmdPort() {
724
- const config = readConfig();
725
- if (!config) {
943
+ const discovery = readDiscovery();
944
+ if (discovery.port == null) {
726
945
  fail('not installed (run: pgserve install)');
727
946
  }
728
- process.stdout.write(`${config.port}\n`);
947
+ process.stdout.write(`${discovery.port}\n`);
729
948
  return 0;
730
949
  }
731
950
 
@@ -747,6 +966,14 @@ function parseDataDir(args) {
747
966
  return path.resolve(v);
748
967
  }
749
968
 
969
+ function parseSocketDir(args) {
970
+ const i = args.indexOf('--socket-dir');
971
+ if (i < 0) return null;
972
+ const v = args[i + 1];
973
+ if (!v) fail('--socket-dir requires a value');
974
+ return path.resolve(v);
975
+ }
976
+
750
977
  function parseUiPort(args) {
751
978
  const i = args.indexOf('--ui-port');
752
979
  if (i < 0) return null;
@@ -807,7 +1034,13 @@ function dispatch(subcommand, args, ctx) {
807
1034
  case 'install':
808
1035
  return cmdInstall(args, ctx);
809
1036
  case 'uninstall':
810
- return cmdUninstall();
1037
+ // canonical-pgserve-pm2-supervision Group 1: the uninstall surface
1038
+ // moved to `src/commands/uninstall.js` so the cohort baseline (pm2
1039
+ // teardown + admin.json supervisor clear + audit-log entry) lives
1040
+ // alongside `src/lib/pm2-args.js` instead of inside this legacy
1041
+ // dispatcher. dispatch() returns a Promise here; the wrapper
1042
+ // already handles both numeric and Promise returns.
1043
+ return import('./commands/uninstall.js').then((mod) => mod.runUninstall());
811
1044
  case 'status':
812
1045
  return cmdStatus(args);
813
1046
  case 'url':
@@ -843,6 +1076,13 @@ function dispatch(subcommand, args, ctx) {
843
1076
  }
844
1077
  case 'auth':
845
1078
  return cmdAuthDispatch(args);
1079
+ case 'verify': {
1080
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
1081
+ // `pgserve verify` is a pure-node command (cosign shellout + HMAC
1082
+ // cache token write); routes through the same async-import pattern
1083
+ // as `uninstall` so the ESM module isn't eagerly loaded.
1084
+ return import('./commands/verify.js').then((mod) => mod.runVerify(args));
1085
+ }
846
1086
  default:
847
1087
  throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
848
1088
  }