pgserve 2.6.0 → 2.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -95,6 +95,35 @@ if (__subcommand === 'serve') {
95
95
  process.argv[2] = 'postmaster';
96
96
  }
97
97
 
98
+ // B4 (v2.6.1): unknown-verb guard. Prior behavior fell through to the
99
+ // bun probe + postgres-server.js which prints help and exits 0 on any
100
+ // argv shape it didn't understand — that masked typos as silent
101
+ // successes (`pgserve doctorr` → exits 0 with help banner; the operator
102
+ // thinks doctor ran). Now we emit a clear `unknown verb` error and
103
+ // exit EX_USAGE (64 per sysexits.h).
104
+ //
105
+ // Allowed shapes that MUST still flow through:
106
+ // - empty argv (no subcommand) → top-level help via postgres-server.js
107
+ // - flags starting with `-` (e.g. `--help`, `--version`) → top-level
108
+ // handlers in postgres-server.js
109
+ // - `postmaster` (canonical long-running entry) + `serve` (alias,
110
+ // already rewritten above)
111
+ // - allowlisted install-subcommands (already dispatched above; if
112
+ // control reaches here with one in __subcommand it means the
113
+ // dispatch path returned without exiting — keep the fallthrough).
114
+ if (
115
+ __subcommand &&
116
+ !__subcommand.startsWith('-') &&
117
+ __subcommand !== 'postmaster' &&
118
+ __subcommand !== 'serve' &&
119
+ !__installSubcommands.has(__subcommand)
120
+ ) {
121
+ process.stderr.write(
122
+ `pgserve: unknown verb "${__subcommand}". Run \`pgserve --help\` for the supported verb list.\n`,
123
+ );
124
+ process.exit(64); // EX_USAGE per sysexits.h
125
+ }
126
+
98
127
  // Detect platform
99
128
  const isWindows = process.platform === 'win32';
100
129
  const bunBin = isWindows ? 'bun.exe' : 'bun';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgserve",
3
- "version": "2.6.0",
3
+ "version": "2.6.1",
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",
@@ -24,9 +24,131 @@
24
24
  const { spawnSync, execFileSync } = require('node:child_process');
25
25
  const crypto = require('node:crypto');
26
26
  const fs = require('node:fs');
27
+ const net = require('node:net');
27
28
  const os = require('node:os');
28
29
  const path = require('node:path');
29
30
 
31
+ // pgserve v2.6.1 — `pgserve install --help` should print usage + exit 0,
32
+ // not run the install (B2 HIGH from QA-RECIPE-B2.md). Single source of
33
+ // truth for the help text so the autopg + pgserve bin invocations show
34
+ // the same surface (Decision #7 of finalize wish).
35
+ const INSTALL_USAGE = `Usage:
36
+ pgserve install [options]
37
+ autopg install [options]
38
+
39
+ Register pgserve under pm2 (Tier A supervisor) with hardened defaults.
40
+
41
+ Options:
42
+ --port <N> TCP port for postgres (default: 5432)
43
+ --data <path> Data directory (default: ~/.autopg/data)
44
+ --socket-dir <path> Unix socket directory (default: $XDG_RUNTIME_DIR/pgserve)
45
+ --ui-port <N> Port for the autopg UI process (default: 8433)
46
+ --ui-host <host> Bind host for the UI (default: 127.0.0.1)
47
+ --no-ui Skip the autopg-ui pm2 process (headless / CI)
48
+ --no-pm2 Skip pm2 registration entirely (Tier B / external supervisor)
49
+ --help, -h Show this help and exit
50
+
51
+ Idempotent: re-running with the same args is a no-op when the existing
52
+ admin.json + pm2 state already matches.
53
+
54
+ See \`pgserve --help\` for the full verb list.
55
+ `;
56
+
57
+ function printInstallUsage(stream = process.stdout) {
58
+ stream.write(INSTALL_USAGE);
59
+ }
60
+
61
+ // pgserve v2.6.1 — `pgserve install` on a host where the chosen port is
62
+ // already in use must fail BEFORE pm2 / admin.json / data-dir side
63
+ // effects (B3 HIGH from QA-RECIPE-B3.md). Pre-flight bind-test on the
64
+ // canonical loopback the postmaster will use; on EADDRINUSE we fail
65
+ // fast with an operator-readable hint pointing at `--port`.
66
+ //
67
+ // Synchronous wrapper around net.createServer().listen() — uses
68
+ // child_process.spawnSync via a self-bind-then-close so the install
69
+ // flow stays synchronous. Returns null on success; throws an Error
70
+ // with `code='EADDRINUSE'` on collision.
71
+ async function assertPortAvailable(port, host = '127.0.0.1') {
72
+ // Test-only escape hatch. Production never sets this env var. Used by
73
+ // tests that assert on `port: 5432` literal output where the host
74
+ // running the test happens to have 5432 bound (dev workstations
75
+ // running a real pgserve). The port-pre-flight contract itself is
76
+ // covered end-to-end by the B3-collision test which intentionally
77
+ // does NOT set this env var so the pre-flight fires.
78
+ if (process.env.PGSERVE_TEST_SKIP_PORT_PREFLIGHT === '1') return null;
79
+
80
+ // Layer 1: connect-probe BOTH IPv4 (127.0.0.1) and IPv6 (::1).
81
+ // Postgres binds both loopback families on startup; any listener on
82
+ // either is an EADDRINUSE for the postmaster. A pure listen()-bind
83
+ // probe can miss this when the conflicting service was started with
84
+ // SO_REUSEADDR or only-one-family, so connect() is the primary check.
85
+ // If connect() succeeds → something is listening → port is busy.
86
+ for (const probeHost of [host, '::1']) {
87
+ const busy = await probePortListening(port, probeHost);
88
+ if (busy) {
89
+ const e = new Error(
90
+ `pgserve install: port ${port} is already in use on ${probeHost} (something is listening).\n` +
91
+ `Specify a different port with \`pgserve install --port <free>\`,\n` +
92
+ `or stop the process bound to ${port} first and retry.`,
93
+ );
94
+ e.code = 'EADDRINUSE';
95
+ throw e;
96
+ }
97
+ }
98
+
99
+ // Layer 2: bind-probe to confirm we ourselves can bind without
100
+ // SO_REUSEADDR conflicts. Catches the case where another process
101
+ // bound the same port with SO_REUSEADDR but isn't currently listening
102
+ // (rare but possible for transitional services).
103
+ return new Promise((resolve, reject) => {
104
+ const server = net.createServer();
105
+ server.once('error', (err) => {
106
+ if (err.code === 'EADDRINUSE') {
107
+ const e = new Error(
108
+ `pgserve install: port ${port} is already in use on ${host} (EADDRINUSE).\n` +
109
+ `Specify a different port with \`pgserve install --port <free>\`,\n` +
110
+ `or stop the process bound to ${port} first and retry.`,
111
+ );
112
+ e.code = 'EADDRINUSE';
113
+ reject(e);
114
+ return;
115
+ }
116
+ // Non-EADDRINUSE errors (e.g. EACCES on privileged ports) — fail
117
+ // closed with the underlying message; no install side effects yet.
118
+ reject(err);
119
+ });
120
+ server.once('listening', () => {
121
+ server.close(() => resolve(null));
122
+ });
123
+ server.listen(port, host);
124
+ });
125
+ }
126
+
127
+ // Promise that resolves true when something is accepting connections at
128
+ // host:port (port is busy), false on ECONNREFUSED (port is free), and
129
+ // rejects on any other error so callers can fail closed.
130
+ function probePortListening(port, host, timeoutMs = 500) {
131
+ return new Promise((resolve, reject) => {
132
+ const socket = new net.Socket();
133
+ let settled = false;
134
+ const finish = (value, err) => {
135
+ if (settled) return;
136
+ settled = true;
137
+ try { socket.destroy(); } catch { /* best-effort */ }
138
+ if (err) reject(err); else resolve(value);
139
+ };
140
+ socket.setTimeout(timeoutMs);
141
+ socket.once('connect', () => finish(true));
142
+ socket.once('timeout', () => finish(false)); // treat slow/no-response as free
143
+ socket.once('error', (err) => {
144
+ if (err.code === 'ECONNREFUSED') return finish(false);
145
+ if (err.code === 'EHOSTUNREACH' || err.code === 'EADDRNOTAVAIL') return finish(false); // IPv6 not configured, etc.
146
+ finish(false, err);
147
+ });
148
+ socket.connect(port, host);
149
+ });
150
+ }
151
+
30
152
  // pgserve singleton (v2.4): the cohort-shared admin-json + socket-dir
31
153
  // helpers live in `src/lib/*.js` as ESM modules (project convention: new
32
154
  // modules ship as .js / ESM). cli-install.cjs runs under node — which
@@ -693,6 +815,13 @@ function cmdAuthDispatch(args) {
693
815
  * wrapper before this module is required (avoids re-resolving here).
694
816
  */
695
817
  async function cmdInstall(args, ctx) {
818
+ // B2 (v2.6.1): `--help` / `-h` MUST short-circuit before any side
819
+ // effects (no pm2 spawn, no admin.json write, no data-dir create).
820
+ if (args.includes('--help') || args.includes('-h')) {
821
+ printInstallUsage();
822
+ process.exit(0);
823
+ }
824
+
696
825
  const { adminJson, socketDirMod, blockedVersions } = await loadCohortModules();
697
826
 
698
827
  // pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 5.
@@ -732,6 +861,36 @@ async function cmdInstall(args, ctx) {
732
861
  }
733
862
 
734
863
  const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
864
+
865
+ // B3 (v2.6.1): pre-flight bind-test the chosen port BEFORE creating
866
+ // pm2 entries / admin.json / data dir. Without this, an operator on
867
+ // a host where 5432 is already occupied gets pm2 reporting `online`
868
+ // while the postmaster crashes silently — divergence between
869
+ // supervisor state and data-plane state. Fail fast with a clear hint.
870
+ try {
871
+ await assertPortAvailable(port);
872
+ } catch (err) {
873
+ if (err.code === 'EADDRINUSE') {
874
+ process.stderr.write(`${err.message}\n`);
875
+ // Belt-and-suspenders for the QA loop-2/2 finding: in QA's test
876
+ // environment, the synchronous `process.exit(1)` path was
877
+ // observed to NOT terminate the process before the install
878
+ // function continued + resolved the wrapper's promise with
879
+ // undefined, which the wrapper then mapped to `process.exit(0)`.
880
+ // Three guarantees here:
881
+ // 1. process.exitCode = 1 → default exit code becomes 1 even
882
+ // if explicit exit is somehow trapped/delayed
883
+ // 2. process.exit(1) → force termination (preferred path)
884
+ // 3. throw err → if exit is delayed, the async
885
+ // function rejects, wrapper's rejection handler does its
886
+ // own process.exit(1), guaranteeing non-zero exit
887
+ process.exitCode = 1;
888
+ process.exit(1);
889
+ throw err;
890
+ }
891
+ throw err;
892
+ }
893
+
735
894
  const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
736
895
 
737
896
  // Set up the canonical socket directory before pm2 launches the
@@ -1153,6 +1312,11 @@ function dispatch(subcommand, args, ctx) {
1153
1312
  module.exports = {
1154
1313
  // Public API for the wrapper.
1155
1314
  dispatch,
1315
+ // B2 + B3: surfaced so unit tests can drive helpers without the
1316
+ // full install side-effect chain.
1317
+ printInstallUsage,
1318
+ assertPortAvailable,
1319
+ INSTALL_USAGE,
1156
1320
  // Auth surface used by cli-ui.cjs.
1157
1321
  verifyAdminPassword,
1158
1322
  getAdminFilePath,