pgserve 2.3.0 → 2.4.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.
@@ -0,0 +1,47 @@
1
+ # pgserve singleton (v2.4) — logrotate config for the pm2 / systemd-user
2
+ # / launchd-supervised postmaster.
3
+ #
4
+ # Postgres' stderr (where pgaudit's audit log lands when the .so is
5
+ # present, and `log_statement=all` lands in the fallback path) is captured
6
+ # by the supervisor and written to:
7
+ #
8
+ # ~/.autopg/logs/autopg-server-out.log
9
+ # ~/.autopg/logs/autopg-server-error.log
10
+ #
11
+ # Symbolic ~/ resolves under each operator's HOME — logrotate runs the
12
+ # rules per-user when invoked with `--state ~/.config/logrotate.state`
13
+ # (see the autopg installer hint). The `su` directive is intentionally
14
+ # absent: this config ships as a USER-context drop-in, not a system one.
15
+ # System-wide rotation under a dedicated `autopg` UNIX user is delivered
16
+ # by the separate `autopg-service-install-system` wish (out of scope for
17
+ # v2.4).
18
+ #
19
+ # Copy or symlink to: /etc/logrotate.d/pgserve (system-wide)
20
+ # Or use per-user: ~/.config/logrotate.d/pgserve
21
+
22
+ ~/.autopg/logs/autopg-server-out.log
23
+ ~/.autopg/logs/autopg-server-error.log
24
+ ~/.pgserve/audit.log
25
+ {
26
+ # Daily rotation keeps audit/forensic events queryable for ~2 weeks
27
+ # without burning disk on a long-running dev host.
28
+ daily
29
+ rotate 14
30
+ # Compress yesterday's log on tomorrow's rotation so today's log stays
31
+ # uncompressed for tail/grep.
32
+ compress
33
+ delaycompress
34
+ # Don't fail when a log file has not been created yet (e.g. fresh
35
+ # `autopg install` before the postmaster has emitted any output).
36
+ missingok
37
+ # Don't fail when a log file is empty.
38
+ notifempty
39
+ # Truncate-in-place so pm2 / systemd / launchd's open file handles
40
+ # keep writing to the rotated file. Avoids the "send SIGHUP and
41
+ # postgres re-opens" dance which we don't want to trigger from a
42
+ # supervisor-agnostic rule.
43
+ copytruncate
44
+ # File mode 0600: audit lines may include literal SQL (parameter
45
+ # values, error contexts) — operator-only on a multi-user host.
46
+ create 0600
47
+ }
@@ -0,0 +1,31 @@
1
+ # pgserve singleton (v2.4) — pgaudit GUC defaults.
2
+ #
3
+ # Loaded by `src/postgres.js::_startPostgres` when the embedded postgres
4
+ # bundle ships pgaudit.so. The postmaster passes these as `-c key=value`
5
+ # pairs at boot; settings.json `_extra` overrides apply on top via the
6
+ # normal curated < extra precedence.
7
+ #
8
+ # When pgaudit.so is NOT present in the bundle (the current state of the
9
+ # `@embedded-postgres/<plat>-<arch>` packages), postgres.js falls back to
10
+ # `log_statement=all`. The cohort sibling `autopg-distribution-cutover`
11
+ # owns shipping the pgaudit binary; this file documents the GUCs that
12
+ # light up the moment the .so lands.
13
+
14
+ shared_preload_libraries = 'pgaudit'
15
+
16
+ # `pgaudit.log = 'all'` is the audit-log breadth contract from the wish.
17
+ # Operators who need a tighter classification (e.g. drop READ to cut log
18
+ # volume on hot read paths) override via:
19
+ # ~/.autopg/settings.json -> postgres._extra.pgaudit.log = 'write,role,ddl'
20
+ pgaudit.log = 'all'
21
+
22
+ # Skip pg_catalog reads — they are constant per session and their
23
+ # inclusion floods the audit log without forensic value.
24
+ pgaudit.log_catalog = off
25
+
26
+ # Audit lines emit on the postmaster's stderr, captured by the supervisor
27
+ # (pm2 -> ~/.autopg/logs/autopg-server-error.log; systemd-user -> journal;
28
+ # launchd -> launchd-managed plist log path). The legacy
29
+ # ~/.pgserve/audit.log path stays rotated by `config/logrotate.d/pgserve`
30
+ # so existing tooling parsing the file keeps working through the
31
+ # migration window.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Embedded PostgreSQL server with true concurrent connections - zero config, auto-provision databases",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "bin/",
13
13
  "src/",
14
+ "config/",
14
15
  "console/dist/",
15
16
  "README.md",
16
17
  "CHANGELOG.md",
@@ -19,7 +20,6 @@
19
20
  "scripts/"
20
21
  ],
21
22
  "scripts": {
22
- "bench": "bun tests/benchmarks/runner.js",
23
23
  "test": "bun test tests/**/*.test.js",
24
24
  "test:watch": "bun test --watch tests/**/*.test.js",
25
25
  "dev": "bun --watch bin/postgres-server.js",
@@ -1,10 +1,15 @@
1
1
  #!/bin/bash
2
2
  # Test that the package works with npx (simulates fresh user install)
3
- # This catches path resolution issues that static analysis can't detect
3
+ # This catches path resolution issues that static analysis can't detect.
4
+ #
5
+ # v2.4 contract change (2026-05-08): `pgserve` (no args) now prints help and
6
+ # exits cleanly. The long-running entry is `pgserve postmaster` (or its
7
+ # `pgserve serve` alias). This test invokes the postmaster directly to verify
8
+ # npx-installed bits boot a real PG.
4
9
 
5
10
  set -e
6
11
 
7
- echo "=== Testing npx compatibility ==="
12
+ echo "=== Testing npx compatibility (v2.4 postmaster entry) ==="
8
13
 
9
14
  # Create temp directory
10
15
  TEST_DIR=$(mktemp -d)
@@ -29,22 +34,39 @@ cd "$TEST_DIR"
29
34
  echo '{"name":"test-npx-install","private":true}' > package.json
30
35
  npm install "./$PACK_FILE" > /dev/null 2>&1
31
36
 
32
- # Test that it starts (with timeout)
33
- echo "Testing server startup via npx..."
34
- timeout 30 npx pgserve --no-cluster --port 15432 > output.log 2>&1 &
37
+ # Verify the bare invocation prints v2.4 help and exits 0 (regression guard
38
+ # for the breaking-cut: pre-v2.4 auto-started a server here).
39
+ echo "Verifying bare 'npx pgserve' prints v2.4 help and exits cleanly..."
40
+ HELP_OUT=$(npx pgserve 2>&1)
41
+ if ! echo "$HELP_OUT" | grep -q "pgserve postmaster"; then
42
+ echo "✗ Bare 'npx pgserve' output does not match v2.4 help (missing 'pgserve postmaster')"
43
+ echo "Output:"
44
+ echo "$HELP_OUT"
45
+ echo "=== npx test FAILED ==="
46
+ exit 1
47
+ fi
48
+ echo "✓ Bare invocation prints v2.4 help"
49
+
50
+ # Test that the postmaster entry starts (with timeout)
51
+ DATA_DIR="$TEST_DIR/data"
52
+ SOCKET_DIR="$TEST_DIR/sock"
53
+ mkdir -p "$DATA_DIR" "$SOCKET_DIR"
54
+ echo "Testing postmaster startup via npx (port 15432)..."
55
+ timeout 30 npx pgserve postmaster --port 15432 --data "$DATA_DIR" --socket-dir "$SOCKET_DIR" > output.log 2>&1 &
35
56
  PID=$!
36
57
 
37
- # Wait for ready signal (Server started successfully!)
58
+ # Wait for ready signal bin/postgres-server.js logs
59
+ # 'pgserve postmaster: ready (Unix socket + TCP)' once both transports are bound.
38
60
  for i in {1..60}; do
39
- if grep -q "Server started successfully" output.log 2>/dev/null; then
40
- echo "✓ Server started successfully via npx"
61
+ if grep -q "pgserve postmaster: ready" output.log 2>/dev/null; then
62
+ echo "✓ Postmaster started successfully via npx"
41
63
  kill $PID 2>/dev/null || true
42
64
  wait $PID 2>/dev/null || true
43
65
  echo "=== npx test PASSED ==="
44
66
  exit 0
45
67
  fi
46
68
  if ! kill -0 $PID 2>/dev/null; then
47
- echo "✗ Server exited unexpectedly"
69
+ echo "✗ Postmaster exited unexpectedly"
48
70
  cat output.log
49
71
  echo "=== npx test FAILED ==="
50
72
  exit 1
@@ -54,7 +76,7 @@ done
54
76
 
55
77
  # Timeout
56
78
  kill $PID 2>/dev/null || true
57
- echo "✗ Server did not start within timeout"
79
+ echo "✗ Postmaster did not start within timeout"
58
80
  cat output.log
59
81
  echo "=== npx test FAILED ==="
60
82
  exit 1
@@ -27,8 +27,37 @@ 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
+
38
+ async function loadCohortModules() {
39
+ const [adminJson, socketDirMod] = await Promise.all([_adminJsonModuleP, _socketDirModuleP]);
40
+ return { adminJson, socketDirMod };
41
+ }
42
+
43
+ // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
44
+ //
45
+ // The pm2 entry name moves from `pgserve` to `autopg-server` so the canonical
46
+ // two-process layout matches the cohort design (`autopg-server` + `autopg-ui`).
47
+ // Tier B operators install a systemd-user unit that ALSO claims the
48
+ // `autopg-server` lifecycle role — `~/.autopg/admin.json` records which
49
+ // supervisor owns the host and `pgserve install` refuses to register pm2 over
50
+ // an existing Tier B record.
51
+ //
52
+ // Default postgres port moves from 8432 (the old bun-proxy listener) to 5432
53
+ // (the postgres standard, since the postmaster now binds TCP directly).
54
+ const PM2_PROCESS_NAME = 'autopg-server';
55
+ // Legacy entry name (pre-v2.4). Self-healing migration in Group 6 will
56
+ // `pm2 delete pgserve` after registering `autopg-server`; we surface the
57
+ // constant here so cleanup tooling and tests can reference it without
58
+ // hardcoding strings.
59
+ const LEGACY_PM2_PROCESS_NAME = 'pgserve';
60
+ const DEFAULT_PORT = 5432;
32
61
 
33
62
  // Console UI is auto-supervised under pm2 alongside the daemon since v2.2.3.
34
63
  // The bundled SPA (console/dist/) is served on this port; operator-facing
@@ -200,7 +229,7 @@ function getEffectiveSupervision() {
200
229
  }
201
230
  }
202
231
 
203
- function buildPm2StartArgs({ scriptPath, port, dataDir }) {
232
+ function buildPm2StartArgs({ scriptPath, port, dataDir, socketDir }) {
204
233
  const logs = {
205
234
  out: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-out.log`),
206
235
  error: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-error.log`),
@@ -215,19 +244,11 @@ function buildPm2StartArgs({ scriptPath, port, dataDir }) {
215
244
  'none',
216
245
  '--max-restarts',
217
246
  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.
247
+ // pm2 ≥ 6.0 dropped `--min-uptime` from the CLI surface — passing it
248
+ // aborts the install. Restart budget (50) is sized to absorb a few
249
+ // long-uptime crashes without burning through.
226
250
  '--restart-delay',
227
251
  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
252
  '--exp-backoff-restart-delay',
232
253
  String(supervision.expBackoffRestartDelayMs),
233
254
  '--max-memory-restart',
@@ -241,28 +262,19 @@ function buildPm2StartArgs({ scriptPath, port, dataDir }) {
241
262
  '--error',
242
263
  logs.error,
243
264
  '--',
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.
265
+ // pgserve singleton (v2.4): pm2 supervises the postmaster directly via
266
+ // the `pgserve postmaster` subcommand — no router, no bun proxy, no
267
+ // daemon control socket. Postgres binds the canonical Unix socket
268
+ // under <socketDir> AND TCP <port> natively. Operators connect via
269
+ // psql -h $XDG_RUNTIME_DIR/pgserve (Unix socket, no -p)
270
+ // psql -h 127.0.0.1 -p 5432 (canonical TCP)
271
+ 'postmaster',
262
272
  '--port',
263
273
  String(port),
264
274
  '--data',
265
275
  dataDir,
276
+ '--socket-dir',
277
+ socketDir,
266
278
  '--log',
267
279
  'warn',
268
280
  ];
@@ -384,21 +396,9 @@ function cmdInstallUi(ctx, options = {}) {
384
396
  return 0;
385
397
  }
386
398
 
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
- }
399
+ // `cmdUninstall` + `cmdUninstallUi` migrated to `src/commands/uninstall.js`
400
+ // as part of canonical-pgserve-pm2-supervision Group 1. The dispatch
401
+ // `case 'uninstall'` above dynamically imports the new module.
402
402
 
403
403
  // ─── Admin password (Basic Auth for `autopg ui`) ─────────────────────────
404
404
 
@@ -429,12 +429,19 @@ function writeAdminFile({ password, rotated = false }) {
429
429
  const hash = hashAdminPassword(password, salt);
430
430
  const file = getAdminFilePath();
431
431
  const now = new Date().toISOString();
432
+ // pgserve singleton (v2.4): admin.json is shared with the supervisor
433
+ // record (`{ supervisor, socketDir, port, installedAt }`) written by
434
+ // `src/lib/admin-json.js`. Merge with any existing fields so the scrypt
435
+ // Basic-Auth scheme can never wipe the supervisor metadata (and vice
436
+ // versa).
437
+ const existing = readAdminFile() || {};
432
438
  const payload = {
439
+ ...existing,
433
440
  scheme: 'scrypt',
434
441
  params: SCRYPT_PARAMS,
435
442
  salt: salt.toString('base64'),
436
443
  hash: hash.toString('base64'),
437
- createdAt: rotated ? readAdminFile()?.createdAt ?? now : now,
444
+ createdAt: rotated ? existing.createdAt ?? now : now,
438
445
  rotatedAt: rotated ? now : null,
439
446
  };
440
447
  ensureConfigDir();
@@ -487,9 +494,14 @@ function verifyAdminPassword(candidate) {
487
494
 
488
495
  function ensureAdminPassword({ rotate = false } = {}) {
489
496
  const existing = readAdminFile();
490
- if (existing && !rotate) return null;
497
+ // pgserve singleton (v2.4): admin.json may exist with only supervisor
498
+ // fields (`{ supervisor, socketDir, port, installedAt }`) and no scrypt
499
+ // scheme yet. Treat "no scrypt scheme" as "no password" so the install
500
+ // path still mints one on first run.
501
+ const hasScryptScheme = !!(existing && existing.scheme === 'scrypt');
502
+ if (hasScryptScheme && !rotate) return null;
491
503
  const password = generateAdminPassword();
492
- writeAdminFile({ password, rotated: !!existing });
504
+ writeAdminFile({ password, rotated: !!hasScryptScheme });
493
505
  return password;
494
506
  }
495
507
 
@@ -532,14 +544,41 @@ function cmdAuthDispatch(args) {
532
544
  * `scriptPath` is the path to `bin/postgres-server.js` resolved by the
533
545
  * wrapper before this module is required (avoids re-resolving here).
534
546
  */
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)');
547
+ async function cmdInstall(args, ctx) {
548
+ const { adminJson, socketDirMod } = await loadCohortModules();
549
+
550
+ // pgserve singleton (v2.4): refuse to install if a different supervisor
551
+ // (Tier B systemd-user / launchd) already owns the host. The cohort
552
+ // contract guarantees one and only one supervisor records itself in
553
+ // `~/.autopg/admin.json`. `pgserve install` is the Tier A (pm2) entry —
554
+ // it asserts that nothing else has already claimed the host before
555
+ // touching pm2.
556
+ const noPm2 = args.includes('--no-pm2');
557
+ const expectedSupervisor = noPm2 ? 'external' : 'pm2';
558
+ try {
559
+ adminJson.assertSupervisor(expectedSupervisor, { configDir: getConfigDir() });
560
+ } catch (err) {
561
+ fail(err.message);
562
+ }
563
+
564
+ if (!noPm2 && !pm2IsAvailable()) {
565
+ 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
566
  }
539
567
 
540
568
  const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
541
569
  const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
542
570
 
571
+ // Set up the canonical socket directory before pm2 launches the
572
+ // postmaster. `ensureSocketDir` enforces mode 0700 and probes writability
573
+ // so failure surfaces here — not as a libpq bind error a few seconds
574
+ // into pm2 backoff.
575
+ const socketDir = parseSocketDir(args) ?? socketDirMod.resolveSocketDir();
576
+ try {
577
+ socketDirMod.ensureSocketDir(socketDir);
578
+ } catch (err) {
579
+ fail(err.message);
580
+ }
581
+
543
582
  const noUi = args.includes('--no-ui');
544
583
  const withUi = args.includes('--with-ui');
545
584
  const redeploy = args.includes('--redeploy');
@@ -559,6 +598,22 @@ function cmdInstall(args, ctx) {
559
598
  return 0;
560
599
  }
561
600
 
601
+ // --no-pm2: skip pm2 register entirely. Used by CI fixture provisioning
602
+ // (no sudo, no pm2 ambient state) and by Tier B-bound hosts that will
603
+ // immediately hand off to `autopg service install`. Still record the
604
+ // supervisor + canonical socket dir + port in admin.json so downstream
605
+ // tooling (pgserve doctor, omni install, genie install) can discover
606
+ // where to connect without an active pm2 entry.
607
+ if (noPm2) {
608
+ if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
609
+ writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
610
+ writeSupervisorRecord(adminJson, { supervisor: 'external', socketDir, port });
611
+ ok(`installed (--no-pm2): supervisor=external, socketDir=${socketDir}, port=${port}`);
612
+ ok(`url: postgres://localhost:${port}/postgres`);
613
+ 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.`);
614
+ return 0;
615
+ }
616
+
562
617
  // --redeploy: full reset. Tear down both processes, then proceed with
563
618
  // a fresh install. Equivalent to `autopg uninstall && autopg install`
564
619
  // but in one verb.
@@ -581,6 +636,7 @@ function cmdInstall(args, ctx) {
581
636
  // don't tear down the live process. Operators wanting a port change
582
637
  // should `uninstall` then `install` (or pass --redeploy).
583
638
  writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
639
+ writeSupervisorRecord(adminJson, { supervisor: 'pm2', socketDir, port });
584
640
  if (!noUi) cmdInstallUi(ctx, { uiPort, uiHost });
585
641
  return 0;
586
642
  }
@@ -588,14 +644,15 @@ function cmdInstall(args, ctx) {
588
644
  ensureLogsDir();
589
645
  if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
590
646
 
591
- const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir });
647
+ const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir, socketDir });
592
648
  const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
593
649
  if (result.status !== 0) {
594
650
  fail(`pm2 start failed (exit ${result.status}). Logs: ${getLogsDir()}/${PM2_PROCESS_NAME}-error.log`);
595
651
  }
596
652
 
597
653
  writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
598
- ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
654
+ writeSupervisorRecord(adminJson, { supervisor: 'pm2', socketDir, port });
655
+ ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (socket: ${socketDir}, data: ${dataDir})`);
599
656
  ok(`url: postgres://localhost:${port}/postgres`);
600
657
 
601
658
  if (noUi) {
@@ -620,29 +677,28 @@ function cmdInstall(args, ctx) {
620
677
  }
621
678
 
622
679
  /**
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.
680
+ * Persist the supervisor record into `~/.autopg/admin.json`. Wraps
681
+ * `writeAdminJson` from the cohort-shared module so the install path can
682
+ * pin its own ISO timestamp + log a friendly diagnostic on the
683
+ * supervisor-lock refusal.
628
684
  */
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})`);
685
+ function writeSupervisorRecord(adminJson, { supervisor, socketDir, port }) {
686
+ try {
687
+ adminJson.writeAdminJson(
688
+ {
689
+ supervisor,
690
+ socketDir,
691
+ port,
692
+ installedAt: new Date().toISOString(),
693
+ },
694
+ { configDir: getConfigDir() },
695
+ );
696
+ } catch (err) {
697
+ // The "Tier B already owns this host" error is the contract failure
698
+ // we surface to operators. Other errors (EACCES, ENOSPC, etc.) just
699
+ // pass through with their stock message.
700
+ fail(err.message);
642
701
  }
643
- ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
644
- cmdUninstallUi();
645
- return 0;
646
702
  }
647
703
 
648
704
  /**
@@ -747,6 +803,14 @@ function parseDataDir(args) {
747
803
  return path.resolve(v);
748
804
  }
749
805
 
806
+ function parseSocketDir(args) {
807
+ const i = args.indexOf('--socket-dir');
808
+ if (i < 0) return null;
809
+ const v = args[i + 1];
810
+ if (!v) fail('--socket-dir requires a value');
811
+ return path.resolve(v);
812
+ }
813
+
750
814
  function parseUiPort(args) {
751
815
  const i = args.indexOf('--ui-port');
752
816
  if (i < 0) return null;
@@ -807,7 +871,13 @@ function dispatch(subcommand, args, ctx) {
807
871
  case 'install':
808
872
  return cmdInstall(args, ctx);
809
873
  case 'uninstall':
810
- return cmdUninstall();
874
+ // canonical-pgserve-pm2-supervision Group 1: the uninstall surface
875
+ // moved to `src/commands/uninstall.js` so the cohort baseline (pm2
876
+ // teardown + admin.json supervisor clear + audit-log entry) lives
877
+ // alongside `src/lib/pm2-args.js` instead of inside this legacy
878
+ // dispatcher. dispatch() returns a Promise here; the wrapper
879
+ // already handles both numeric and Promise returns.
880
+ return import('./commands/uninstall.js').then((mod) => mod.runUninstall());
811
881
  case 'status':
812
882
  return cmdStatus(args);
813
883
  case 'url':