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
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Cosign keyless OIDC binary verifier.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 4.
5
+ *
6
+ * Per Decision P5 (locked), we shell out to the `cosign` CLI rather than
7
+ * vendoring sigstore-rs. Verification model:
8
+ *
9
+ * - Each binary distributed by an automagik release ships with a
10
+ * paired Sigstore bundle file at `<binary>.bundle` (modern keyless
11
+ * OIDC attestation, JSON-encoded).
12
+ * - We invoke `cosign verify-blob --bundle <bundle>
13
+ * --certificate-identity-regexp <re> --certificate-oidc-issuer <iss>
14
+ * <binary>` — once per entry in the trust list.
15
+ * - First entry that exits 0 wins. We return its identity + tier.
16
+ * - If every entry fails, we surface the most-specific cosign diagnostic
17
+ * (the last exit) so the operator knows which trust root we tried.
18
+ *
19
+ * Resolution of the cosign executable:
20
+ * 1. Caller-supplied `cosignBin` (used by tests + `--cosign-bin` flag)
21
+ * 2. `cosign` on `$PATH`
22
+ * 3. Cached static binary at `~/.pgserve/bin/cosign`
23
+ * 4. Download official static binary from sigstore release (offline by
24
+ * default — only triggered when `allowFetch: true` is passed)
25
+ *
26
+ * Returns a tagged union:
27
+ * { ok: true, identity, tier, sha256, cosignBin, bundle }
28
+ * { ok: false, reason, detail?, identityChain? }
29
+ */
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import crypto from 'node:crypto';
33
+ import fs from 'node:fs';
34
+ import os from 'node:os';
35
+ import path from 'node:path';
36
+
37
+ import { TRUSTED_IDENTITIES } from './trust-list.js';
38
+
39
+ export const COSIGN_TIER = 'cosign_signed';
40
+ export const COSIGN_BIN_DIR = path.join(os.homedir(), '.pgserve', 'bin');
41
+ export const COSIGN_BIN_FILE = path.join(COSIGN_BIN_DIR, process.platform === 'win32' ? 'cosign.exe' : 'cosign');
42
+
43
+ const COSIGN_RELEASE_VERSION = 'v2.2.4';
44
+ const COSIGN_RELEASE_BASE = `https://github.com/sigstore/cosign/releases/download/${COSIGN_RELEASE_VERSION}`;
45
+
46
+ /**
47
+ * Compute sha256 of a file. Returns lowercase hex.
48
+ */
49
+ export function sha256File(filePath) {
50
+ const hash = crypto.createHash('sha256');
51
+ const buf = fs.readFileSync(filePath);
52
+ hash.update(buf);
53
+ return hash.digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Resolve the sidecar bundle path for a given binary path. Convention:
58
+ * `<binary>.bundle`. Operators that publish detached `.sig` + `.cert` can
59
+ * regenerate a bundle with `cosign sign-blob --bundle <path>.bundle`; we
60
+ * intentionally only support the bundle form to keep the surface narrow.
61
+ */
62
+ export function resolveBundlePath(binaryPath) {
63
+ return `${binaryPath}.bundle`;
64
+ }
65
+
66
+ /**
67
+ * Resolve which `cosign` executable to use. Returns a string path or null
68
+ * if no cosign is available and `allowFetch: false`.
69
+ *
70
+ * PATH probing is implemented in-process (rather than shelling out to
71
+ * `which` / `where`) so the resolver works inside test harnesses that
72
+ * scrub PATH down to a single stub directory.
73
+ */
74
+ export function resolveCosignBin({ cosignBin, allowFetch = false } = {}) {
75
+ if (cosignBin && fs.existsSync(cosignBin)) return cosignBin;
76
+
77
+ const fromPath = lookupOnPath(process.platform === 'win32' ? 'cosign.exe' : 'cosign');
78
+ if (fromPath) return fromPath;
79
+
80
+ // Cached static binary.
81
+ if (fs.existsSync(COSIGN_BIN_FILE)) return COSIGN_BIN_FILE;
82
+
83
+ if (!allowFetch) return null;
84
+
85
+ try {
86
+ return fetchCosignBin();
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ function lookupOnPath(name) {
93
+ const PATH = process.env.PATH || '';
94
+ if (!PATH) return null;
95
+ const sep = process.platform === 'win32' ? ';' : ':';
96
+ const dirs = PATH.split(sep).filter(Boolean);
97
+ for (const dir of dirs) {
98
+ const candidate = path.join(dir, name);
99
+ try {
100
+ const stat = fs.statSync(candidate);
101
+ if (stat.isFile()) {
102
+ // On POSIX, ensure it's executable; on Windows, file existence is enough.
103
+ if (process.platform === 'win32') return candidate;
104
+ if ((stat.mode & 0o111) !== 0) return candidate;
105
+ }
106
+ } catch {
107
+ // missing or stat error — try next dir
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+
113
+ /**
114
+ * Download the official cosign static binary into `~/.pgserve/bin/cosign`.
115
+ * Synchronous — used by `pgserve verify` when no cosign is on PATH and the
116
+ * operator opts into the fetch (see Decision P5: "if cosign not on PATH,
117
+ * pgserve install shells out to a downloader to fetch the official static
118
+ * binary into ~/.pgserve/bin/cosign").
119
+ *
120
+ * Network-dependent. Throws on failure. Tests stub via `cosignBin` instead
121
+ * of this code path.
122
+ */
123
+ export function fetchCosignBin({
124
+ releaseBase = COSIGN_RELEASE_BASE,
125
+ targetFile = COSIGN_BIN_FILE,
126
+ targetDir = COSIGN_BIN_DIR,
127
+ } = {}) {
128
+ fs.mkdirSync(targetDir, { recursive: true, mode: 0o755 });
129
+ const assetName = pickCosignAssetName();
130
+ const url = `${releaseBase}/${assetName}`;
131
+ const tmp = `${targetFile}.tmp.${process.pid}`;
132
+ downloadToFileSync(url, tmp);
133
+ fs.chmodSync(tmp, 0o755);
134
+ fs.renameSync(tmp, targetFile);
135
+ return targetFile;
136
+ }
137
+
138
+ function pickCosignAssetName() {
139
+ const arch = process.arch === 'x64' ? 'amd64' : process.arch === 'arm64' ? 'arm64' : process.arch;
140
+ if (process.platform === 'darwin') return `cosign-darwin-${arch}`;
141
+ if (process.platform === 'win32') return `cosign-windows-${arch}.exe`;
142
+ return `cosign-linux-${arch}`;
143
+ }
144
+
145
+ function downloadToFileSync(url, destPath) {
146
+ // Node has no built-in synchronous HTTP. We fall back to spawning curl
147
+ // since this only runs once per host (cached afterward) and curl is
148
+ // ubiquitous on the supported platforms.
149
+ const curl = spawnSync('curl', ['-fsSL', '-o', destPath, url], {
150
+ stdio: ['ignore', 'pipe', 'pipe'],
151
+ });
152
+ if (curl.status === 0) return;
153
+ // Fallback: try wget.
154
+ const wget = spawnSync('wget', ['-qO', destPath, url], { stdio: ['ignore', 'pipe', 'pipe'] });
155
+ if (wget.status === 0) return;
156
+ throw new Error(
157
+ `cosign-verify: failed to download cosign from ${url} (curl exit ${curl.status}, wget exit ${wget?.status ?? 'n/a'})`,
158
+ );
159
+ }
160
+
161
+ /**
162
+ * Verify a binary with cosign.
163
+ *
164
+ * @param {string} binaryPath
165
+ * @param {object} [options]
166
+ * @param {string} [options.cosignBin] override cosign executable
167
+ * @param {string} [options.bundlePath] override bundle sidecar path
168
+ * @param {Array} [options.trustList] override hardcoded TRUSTED_IDENTITIES
169
+ * @param {boolean}[options.allowFetch] allow fetching the cosign binary if missing
170
+ * @returns {{ ok: true, identity, tier, sha256, cosignBin, bundle, identityChain }
171
+ * | { ok: false, reason, detail?, identityChain? }}
172
+ */
173
+ export function verifyBinary(binaryPath, options = {}) {
174
+ if (typeof binaryPath !== 'string' || binaryPath.length === 0) {
175
+ return { ok: false, reason: 'invalid-args', detail: 'binaryPath required' };
176
+ }
177
+ if (!fs.existsSync(binaryPath)) {
178
+ return { ok: false, reason: 'binary-missing', detail: binaryPath };
179
+ }
180
+ let stat;
181
+ try {
182
+ stat = fs.statSync(binaryPath);
183
+ } catch (err) {
184
+ return { ok: false, reason: 'binary-unreadable', detail: err.message };
185
+ }
186
+ if (!stat.isFile()) {
187
+ return { ok: false, reason: 'binary-not-a-file', detail: binaryPath };
188
+ }
189
+
190
+ const bundlePath = options.bundlePath || resolveBundlePath(binaryPath);
191
+ if (!fs.existsSync(bundlePath)) {
192
+ return {
193
+ ok: false,
194
+ reason: 'bundle-missing',
195
+ detail: `expected sigstore bundle at ${bundlePath} (run \`cosign sign-blob --bundle ${bundlePath} ${binaryPath}\` to attest)`,
196
+ };
197
+ }
198
+
199
+ const trustList = options.trustList || TRUSTED_IDENTITIES;
200
+ if (!Array.isArray(trustList) || trustList.length === 0) {
201
+ return { ok: false, reason: 'empty-trust-list' };
202
+ }
203
+
204
+ const cosignBin = resolveCosignBin({
205
+ cosignBin: options.cosignBin,
206
+ allowFetch: options.allowFetch === true,
207
+ });
208
+ if (!cosignBin) {
209
+ return {
210
+ ok: false,
211
+ reason: 'cosign-missing',
212
+ detail:
213
+ 'no `cosign` binary on $PATH or in ~/.pgserve/bin/cosign — install cosign or rerun with --allow-fetch',
214
+ };
215
+ }
216
+
217
+ const sha256 = sha256File(binaryPath);
218
+ const identityChain = [];
219
+ let lastFailure = null;
220
+
221
+ for (const identity of trustList) {
222
+ if (!identity || !identity.id || !identity.issuer || !identity.identityRegexp) {
223
+ identityChain.push({ id: identity?.id || '<malformed>', status: 'skipped' });
224
+ continue;
225
+ }
226
+ const result = invokeCosign({
227
+ cosignBin,
228
+ bundlePath,
229
+ binaryPath,
230
+ identity,
231
+ });
232
+ if (result.ok) {
233
+ identityChain.push({ id: identity.id, status: 'matched' });
234
+ return {
235
+ ok: true,
236
+ identity: identity.id,
237
+ publisher: identity.publisher,
238
+ tier: COSIGN_TIER,
239
+ sha256,
240
+ cosignBin,
241
+ bundle: bundlePath,
242
+ identityChain,
243
+ };
244
+ }
245
+ identityChain.push({ id: identity.id, status: 'rejected', exitCode: result.exitCode });
246
+ lastFailure = result;
247
+ }
248
+
249
+ return {
250
+ ok: false,
251
+ reason: 'no-trust-match',
252
+ detail: lastFailure?.stderr || 'cosign rejected the binary against every trust root',
253
+ identityChain,
254
+ };
255
+ }
256
+
257
+ function invokeCosign({ cosignBin, bundlePath, binaryPath, identity }) {
258
+ const args = [
259
+ 'verify-blob',
260
+ '--bundle', bundlePath,
261
+ '--certificate-identity-regexp', identity.identityRegexp,
262
+ '--certificate-oidc-issuer', identity.issuer,
263
+ binaryPath,
264
+ ];
265
+ const proc = spawnSync(cosignBin, args, {
266
+ encoding: 'utf8',
267
+ stdio: ['ignore', 'pipe', 'pipe'],
268
+ });
269
+ if (proc.status === 0) {
270
+ return { ok: true, stdout: proc.stdout || '' };
271
+ }
272
+ return {
273
+ ok: false,
274
+ exitCode: typeof proc.status === 'number' ? proc.status : -1,
275
+ stderr: (proc.stderr || proc.stdout || '').trim().slice(0, 4096),
276
+ };
277
+ }
package/src/index.js CHANGED
@@ -1,49 +1,16 @@
1
1
  /**
2
- * pgserve - Embedded PostgreSQL Server
2
+ * pgserve Embedded PostgreSQL Server (singleton, v2.4+)
3
3
  *
4
- * True concurrent connections, zero config, auto-provision databases.
5
- * Uses embedded-postgres (real PostgreSQL binaries).
4
+ * Public surface after the `pgserve-singleton-no-proxy` Group 2 deletion:
5
+ * the bun proxy data plane, daemon control socket, libpq protocol
6
+ * rewriting, and SO_PEERCRED handshake are gone. Operators interact with
7
+ * pgserve through the CLI (`bin/pgserve-wrapper.cjs`), the postmaster
8
+ * subcommand (`bin/postgres-server.js postmaster`), and the cohort-shared
9
+ * helpers under `src/lib/`.
10
+ *
11
+ * `PostgresManager` is exported for tests and integrators that want to
12
+ * embed a postgres instance programmatically — it is the same class the
13
+ * postmaster subcommand instantiates.
6
14
  */
7
15
 
8
- // Main exports
9
- export { MultiTenantRouter, startMultiTenantServer } from './router.js';
10
16
  export { PostgresManager } from './postgres.js';
11
- export { SyncManager } from './sync.js';
12
- export { RestoreManager } from './restore.js';
13
- export { Dashboard } from './dashboard.js';
14
- export { StatsCollector } from './stats-collector.js';
15
- export { StatsDashboard } from './stats-dashboard.js';
16
- export {
17
- PgserveDaemon,
18
- startDaemon,
19
- stopDaemon,
20
- resolveControlSocketDir,
21
- resolveControlSocketPath,
22
- resolvePidLockPath,
23
- resolveLibpqCompatPath,
24
- acquirePidLock,
25
- isProcessAlive,
26
- } from './daemon.js';
27
- export {
28
- buildDaemonArgs,
29
- daemonClientOptions,
30
- ensureDaemon,
31
- probeDaemon,
32
- resolveBundledCliBin,
33
- } from './sdk.js';
34
- export {
35
- derivePackageFingerprint,
36
- deriveScriptFingerprint,
37
- fingerprintFromCred,
38
- findNearestPackageJson,
39
- readPackageName,
40
- readPersistFlag,
41
- } from './fingerprint.js';
42
- export {
43
- hashToken,
44
- mintToken,
45
- parseTcpAuth,
46
- } from './tokens.js';
47
-
48
- // Default export
49
- export { startMultiTenantServer as default } from './router.js';
@@ -0,0 +1,202 @@
1
+ /**
2
+ * `~/.autopg/admin.json` reader, atomic writer, and supervisor-assertion.
3
+ *
4
+ * Cohort-shared module — co-owned with `canonical-pgserve-pm2-supervision`
5
+ * Group 1. Schema for the supervisor record:
6
+ *
7
+ * {
8
+ * supervisor: "pm2" | "systemd-user" | "launchd" | "external",
9
+ * socketDir: "<absolute path>",
10
+ * port: <integer>,
11
+ * installedAt: "<ISO 8601 timestamp>"
12
+ * }
13
+ *
14
+ * The file is shared with the Basic-Auth scrypt password record used by the
15
+ * autopg console UI (`scheme`/`salt`/`hash`/...). This module merges with
16
+ * any pre-existing fields it does not own — `writeAdminJson` is additive,
17
+ * never destructive — so both tenants coexist on the same file.
18
+ *
19
+ * Hard contract — refuses to downgrade supervision authority:
20
+ * - If the existing file records `systemd-user` or `launchd`, refuses to
21
+ * write `pm2` or `external`. Operators must `autopg service uninstall`
22
+ * first to migrate Tier B → Tier A.
23
+ * - `assertSupervisor(expected)` throws when the actual supervisor differs
24
+ * so callers fail fast with a structured remediation hint.
25
+ *
26
+ * Atomic semantics: write to `<file>.tmp.<pid>`, fsync, then
27
+ * `fs.renameSync` to the target. mode 0600 enforced via `fs.chmodSync`
28
+ * after write.
29
+ */
30
+
31
+ import fs from 'fs';
32
+ import os from 'os';
33
+ import path from 'path';
34
+
35
+ export const ADMIN_FILE_NAME = 'admin.json';
36
+ export const ADMIN_FILE_MODE = 0o600;
37
+
38
+ export const SUPERVISOR_VALUES = Object.freeze([
39
+ 'pm2',
40
+ 'systemd-user',
41
+ 'launchd',
42
+ 'external',
43
+ ]);
44
+
45
+ /** Supervisors that own the postmaster lifecycle via an OS service unit. */
46
+ const TIER_B_SUPERVISORS = new Set(['systemd-user', 'launchd']);
47
+
48
+ /**
49
+ * Resolve the autopg config directory.
50
+ *
51
+ * Honors `AUTOPG_CONFIG_DIR` (current var) first, then `PGSERVE_CONFIG_DIR`
52
+ * (legacy soft-rename), then `$HOME/.autopg`. Mirrors the precedence in
53
+ * `src/cli-install.cjs` and `src/settings-loader.cjs`.
54
+ */
55
+ export function getDefaultConfigDir() {
56
+ return (
57
+ process.env.AUTOPG_CONFIG_DIR
58
+ || process.env.PGSERVE_CONFIG_DIR
59
+ || path.join(os.homedir(), '.autopg')
60
+ );
61
+ }
62
+
63
+ export function getAdminFilePath(configDir = getDefaultConfigDir()) {
64
+ return path.join(configDir, ADMIN_FILE_NAME);
65
+ }
66
+
67
+ function ensureConfigDir(configDir) {
68
+ if (!fs.existsSync(configDir)) {
69
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Read the admin.json record. Returns the parsed object on success, `null`
75
+ * when the file is missing or unreadable. Never throws — callers treat
76
+ * "missing" and "broken" identically.
77
+ */
78
+ export function readAdminJson({ configDir = getDefaultConfigDir() } = {}) {
79
+ const file = getAdminFilePath(configDir);
80
+ let raw;
81
+ try {
82
+ raw = fs.readFileSync(file, 'utf8');
83
+ } catch {
84
+ return null;
85
+ }
86
+ try {
87
+ const parsed = JSON.parse(raw);
88
+ return (parsed && typeof parsed === 'object') ? parsed : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function isPlainObject(v) {
95
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
96
+ }
97
+
98
+ function validateSupervisorRecord(record) {
99
+ if (!isPlainObject(record)) {
100
+ throw new TypeError('admin-json: record must be an object');
101
+ }
102
+ if (!SUPERVISOR_VALUES.includes(record.supervisor)) {
103
+ throw new TypeError(
104
+ `admin-json: invalid supervisor "${record.supervisor}". `
105
+ + `Expected one of: ${SUPERVISOR_VALUES.join(', ')}`,
106
+ );
107
+ }
108
+ if (typeof record.socketDir !== 'string' || record.socketDir.length === 0) {
109
+ throw new TypeError('admin-json: socketDir must be a non-empty string');
110
+ }
111
+ if (!Number.isInteger(record.port) || record.port < 1 || record.port > 65535) {
112
+ throw new TypeError(`admin-json: port must be an integer in [1, 65535]; got ${record.port}`);
113
+ }
114
+ if (typeof record.installedAt !== 'string' || record.installedAt.length === 0) {
115
+ throw new TypeError('admin-json: installedAt must be a non-empty ISO 8601 string');
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Atomic merge-write of the supervisor record.
121
+ *
122
+ * Reads any existing admin.json, layers the supplied supervisor fields on
123
+ * top (preserving unrelated fields like the scrypt Basic-Auth scheme), and
124
+ * writes the result via tmp+rename with mode 0600.
125
+ *
126
+ * Refuses with a structured error when the existing record names a Tier B
127
+ * supervisor (`systemd-user` / `launchd`) and the incoming record would
128
+ * downgrade authority. Use `autopg service uninstall` to migrate Tier B →
129
+ * Tier A explicitly.
130
+ */
131
+ export function writeAdminJson(record, { configDir = getDefaultConfigDir() } = {}) {
132
+ validateSupervisorRecord(record);
133
+
134
+ ensureConfigDir(configDir);
135
+ const file = getAdminFilePath(configDir);
136
+ const existing = readAdminJson({ configDir }) ?? {};
137
+
138
+ if (
139
+ TIER_B_SUPERVISORS.has(existing.supervisor)
140
+ && existing.supervisor !== record.supervisor
141
+ ) {
142
+ const err = new Error(
143
+ `pgserve: refusing to overwrite admin.json — existing supervisor is `
144
+ + `"${existing.supervisor}" (Tier B); cannot register "${record.supervisor}". `
145
+ + `Run \`autopg service uninstall\` first to migrate to Tier A.`,
146
+ );
147
+ err.code = 'EADMINSUPERVISORLOCK';
148
+ err.existingSupervisor = existing.supervisor;
149
+ err.requestedSupervisor = record.supervisor;
150
+ throw err;
151
+ }
152
+
153
+ const merged = {
154
+ ...existing,
155
+ supervisor: record.supervisor,
156
+ socketDir: record.socketDir,
157
+ port: record.port,
158
+ installedAt: record.installedAt,
159
+ };
160
+
161
+ const tmp = `${file}.tmp.${process.pid}`;
162
+ const json = `${JSON.stringify(merged, null, 2)}\n`;
163
+ fs.writeFileSync(tmp, json, { mode: ADMIN_FILE_MODE });
164
+ fs.renameSync(tmp, file);
165
+ fs.chmodSync(file, ADMIN_FILE_MODE);
166
+
167
+ return merged;
168
+ }
169
+
170
+ /**
171
+ * Throw when the on-disk supervisor differs from `expected`. Returns the
172
+ * record on match. Used by callers that must refuse to operate when the
173
+ * host has already been claimed by a different supervisor — e.g.
174
+ * `pgserve install` (Tier A) refusing to run on a Tier B host.
175
+ *
176
+ * Missing file is NOT an error here — there's nothing to assert against.
177
+ * The caller should treat "no record" as "free to install".
178
+ */
179
+ export function assertSupervisor(expected, { configDir = getDefaultConfigDir() } = {}) {
180
+ if (!SUPERVISOR_VALUES.includes(expected)) {
181
+ throw new TypeError(
182
+ `admin-json: invalid expected supervisor "${expected}". `
183
+ + `Expected one of: ${SUPERVISOR_VALUES.join(', ')}`,
184
+ );
185
+ }
186
+ const existing = readAdminJson({ configDir });
187
+ if (!existing || !existing.supervisor) return null;
188
+ if (existing.supervisor !== expected) {
189
+ const err = new Error(
190
+ `pgserve: admin.json supervisor mismatch — expected "${expected}", `
191
+ + `found "${existing.supervisor}". `
192
+ + `${TIER_B_SUPERVISORS.has(existing.supervisor)
193
+ ? 'Run `autopg service uninstall` to migrate to Tier A.'
194
+ : 'Run `pgserve uninstall` to clear the existing record.'}`,
195
+ );
196
+ err.code = 'EADMINSUPERVISORMISMATCH';
197
+ err.expected = expected;
198
+ err.actual = existing.supervisor;
199
+ throw err;
200
+ }
201
+ return existing;
202
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Cohort-shared pm2 launch builder for the canonical-pgserve-pm2-supervision
3
+ * wish (Group 1).
4
+ *
5
+ * Exports:
6
+ * PM2_HARDENED_DEFAULTS — baseline hardening values pinned in the wish
7
+ * SERVICE_MEMORY_LIMITS — per-service maxMemoryRestart map
8
+ * buildPm2StartArgs(serviceName, opts) — factory returning the argv passed
9
+ * to `pm2 ...`
10
+ *
11
+ * Per Decision 3 of the wish, the constants stay duplicated across
12
+ * `autopg`, `genie`, and `omni` rather than introducing a shared package —
13
+ * the values are pinned here and copied verbatim into the genie + omni
14
+ * installers.
15
+ *
16
+ * Note on the autopg daemon (`autopg-server`): its own pm2 args are still
17
+ * built inside `src/cli-install.cjs` with a higher restart budget and a
18
+ * larger memory ceiling, because postgres specifics demand more headroom
19
+ * (see PR #57 review notes). The values exported here are the cohort
20
+ * baseline used by the companion `autopg-ui` process and by the cross-repo
21
+ * services (`genie-serve`, `omni-api`, `omni-nats`).
22
+ */
23
+
24
+ import path from 'node:path';
25
+
26
+ export const PM2_HARDENED_DEFAULTS = Object.freeze({
27
+ maxRestarts: 10,
28
+ restartDelayMs: 5000,
29
+ killTimeoutMs: 20000,
30
+ logDateFormat: 'YYYY-MM-DD HH:mm:ss.SSS',
31
+ // pm2 launches both genie and omni binaries via `#!/usr/bin/env bun`
32
+ // shebangs. `--interpreter bun` triggers pm2's ESM/require crash on
33
+ // top-level await; shebang resolution side-steps the issue.
34
+ // Empirically validated 2026-04-30 (Decision 4 of the wish).
35
+ interpreter: 'none',
36
+ });
37
+
38
+ export const SERVICE_MEMORY_LIMITS = Object.freeze({
39
+ 'autopg-server': '2G',
40
+ 'autopg-ui': '256M',
41
+ 'genie-serve': '2G',
42
+ 'omni-api': '2G',
43
+ 'omni-nats': '1G',
44
+ });
45
+
46
+ export const DEFAULT_MAX_MEMORY = '2G';
47
+
48
+ const VALID_SERVICE_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
49
+
50
+ /**
51
+ * Resolve the maxMemoryRestart string for a service. Honors caller override
52
+ * first, then SERVICE_MEMORY_LIMITS, then DEFAULT_MAX_MEMORY.
53
+ */
54
+ export function resolveMaxMemory(serviceName, override) {
55
+ if (override) return override;
56
+ return SERVICE_MEMORY_LIMITS[serviceName] || DEFAULT_MAX_MEMORY;
57
+ }
58
+
59
+ /**
60
+ * Build the argv to register a long-lived service under pm2.
61
+ *
62
+ * @param {string} serviceName — pm2 process name (also used in default log
63
+ * filenames). Must match `^[A-Za-z][A-Za-z0-9_-]{0,63}$`.
64
+ * @param {object} opts
65
+ * @param {string} opts.scriptPath — script pm2 invokes
66
+ * @param {string} opts.logsDir — directory for `<name>-out.log` /
67
+ * `<name>-error.log`
68
+ * @param {string[]} [opts.scriptArgs] — args passed after `--` to the script
69
+ * @param {string} [opts.maxMemoryRestart] — override the per-service default
70
+ * (e.g. `4G` on big-iron hosts)
71
+ * @param {object} [opts.overrides] — override individual hardening values
72
+ * (`maxRestarts`, `restartDelayMs`, `killTimeoutMs`, `logDateFormat`,
73
+ * `interpreter`)
74
+ * @returns {string[]} args to pass to `pm2`
75
+ */
76
+ export function buildPm2StartArgs(serviceName, opts) {
77
+ if (typeof serviceName !== 'string' || !VALID_SERVICE_NAME.test(serviceName)) {
78
+ throw new TypeError(
79
+ `pm2-args: serviceName must match /^[A-Za-z][A-Za-z0-9_-]{0,63}$/; got ${JSON.stringify(serviceName)}`,
80
+ );
81
+ }
82
+ if (!opts || typeof opts !== 'object') {
83
+ throw new TypeError('pm2-args: opts is required');
84
+ }
85
+ if (typeof opts.scriptPath !== 'string' || opts.scriptPath.length === 0) {
86
+ throw new TypeError('pm2-args: opts.scriptPath must be a non-empty string');
87
+ }
88
+ if (typeof opts.logsDir !== 'string' || opts.logsDir.length === 0) {
89
+ throw new TypeError('pm2-args: opts.logsDir must be a non-empty string');
90
+ }
91
+
92
+ const overrides = opts.overrides || {};
93
+ const maxRestarts = overrides.maxRestarts ?? PM2_HARDENED_DEFAULTS.maxRestarts;
94
+ const restartDelayMs = overrides.restartDelayMs ?? PM2_HARDENED_DEFAULTS.restartDelayMs;
95
+ const killTimeoutMs = overrides.killTimeoutMs ?? PM2_HARDENED_DEFAULTS.killTimeoutMs;
96
+ const logDateFormat = overrides.logDateFormat ?? PM2_HARDENED_DEFAULTS.logDateFormat;
97
+ const interpreter = overrides.interpreter ?? PM2_HARDENED_DEFAULTS.interpreter;
98
+ const maxMemoryRestart = resolveMaxMemory(serviceName, opts.maxMemoryRestart);
99
+
100
+ const argv = [
101
+ 'start',
102
+ opts.scriptPath,
103
+ '--name', serviceName,
104
+ '--interpreter', interpreter,
105
+ '--max-restarts', String(maxRestarts),
106
+ '--restart-delay', String(restartDelayMs),
107
+ '--max-memory-restart', maxMemoryRestart,
108
+ '--kill-timeout', String(killTimeoutMs),
109
+ '--log-date-format', logDateFormat,
110
+ '--output', path.join(opts.logsDir, `${serviceName}-out.log`),
111
+ '--error', path.join(opts.logsDir, `${serviceName}-error.log`),
112
+ ];
113
+
114
+ const scriptArgs = Array.isArray(opts.scriptArgs) ? opts.scriptArgs : [];
115
+ if (scriptArgs.length > 0) {
116
+ argv.push('--', ...scriptArgs);
117
+ }
118
+ return argv;
119
+ }