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,241 @@
1
+ /**
2
+ * `autopg uninstall` — Tier A teardown of the rootless pm2 supervisor.
3
+ *
4
+ * Group 1 of the canonical-pgserve-pm2-supervision wish. Idempotent.
5
+ *
6
+ * Removes:
7
+ * - pm2 entry `autopg-server` (the postmaster, registered by `autopg install`)
8
+ * - pm2 entry `autopg-ui` (the console SPA, registered by `autopg install`)
9
+ * - the supervisor record in `~/.autopg/admin.json` (the four supervisor
10
+ * fields managed by `src/lib/admin-json.js` — preserves the scrypt
11
+ * Basic-Auth scheme so a re-install can keep the same admin password).
12
+ *
13
+ * Preserves:
14
+ * - the data directory under `~/.autopg/data/`
15
+ * - `~/.autopg/config.json`
16
+ * - `admin.json` auth fields (scheme/salt/hash/createdAt/rotatedAt/...)
17
+ *
18
+ * Writes one JSONL audit-log entry to `<configDir>/audit.log`.
19
+ *
20
+ * Idempotent contract: running uninstall twice in a row is a no-op on the
21
+ * second call. After uninstall, a subsequent `autopg install` succeeds
22
+ * without a Tier-B-refusal false positive — `assertSupervisor` treats a
23
+ * missing supervisor field as "host is free to install".
24
+ */
25
+
26
+ import { execFileSync, spawnSync } from 'node:child_process';
27
+ import fs from 'node:fs';
28
+ import os from 'node:os';
29
+ import path from 'node:path';
30
+
31
+ import {
32
+ ADMIN_FILE_MODE,
33
+ getAdminFilePath,
34
+ readAdminJson,
35
+ } from '../lib/admin-json.js';
36
+
37
+ export const TIER_A_PM2_PROCESSES = Object.freeze(['autopg-server', 'autopg-ui']);
38
+ export const SUPERVISOR_FIELDS = Object.freeze([
39
+ 'supervisor',
40
+ 'socketDir',
41
+ 'port',
42
+ 'installedAt',
43
+ ]);
44
+
45
+ function getConfigDir() {
46
+ return (
47
+ process.env.AUTOPG_CONFIG_DIR
48
+ || process.env.PGSERVE_CONFIG_DIR
49
+ || path.join(os.homedir(), '.autopg')
50
+ );
51
+ }
52
+
53
+ function pm2IsAvailable() {
54
+ try {
55
+ execFileSync('pm2', ['--version'], {
56
+ encoding: 'utf8',
57
+ timeout: 3000,
58
+ stdio: ['ignore', 'pipe', 'ignore'],
59
+ });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function pm2GetProcess(name) {
67
+ try {
68
+ const out = execFileSync('pm2', ['jlist'], {
69
+ encoding: 'utf8',
70
+ timeout: 5000,
71
+ stdio: ['ignore', 'pipe', 'ignore'],
72
+ });
73
+ const list = JSON.parse(out);
74
+ return list.find((p) => p && p.name === name) || null;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Always-attempt pm2 delete. `pm2 delete <missing>` exits non-zero but the
82
+ * call is idempotent server-side, so any non-zero exit is treated as
83
+ * "already absent" rather than a hard failure. We snapshot pm2 jlist
84
+ * BEFORE the delete to report whether the entry actually existed.
85
+ */
86
+ function tearDownPm2(name) {
87
+ if (!pm2IsAvailable()) {
88
+ return { name, removed: false, status: 'pm2-missing' };
89
+ }
90
+ const before = pm2GetProcess(name);
91
+ const res = spawnSync('pm2', ['delete', name], {
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ timeout: 10_000,
94
+ });
95
+ if (res.status === 0) {
96
+ return {
97
+ name,
98
+ removed: !!before,
99
+ status: before ? 'removed' : 'already-absent',
100
+ };
101
+ }
102
+ return {
103
+ name,
104
+ removed: false,
105
+ status: 'already-absent',
106
+ exitCode: res.status,
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Atomically clear the supervisor fields from admin.json. Preserves all
112
+ * other fields (notably the scrypt Basic-Auth scheme written by
113
+ * `cli-install.cjs`'s `writeAdminFile`). Removes the file entirely if
114
+ * clearing leaves an empty object.
115
+ *
116
+ * Returns { changed, file, hadSupervisor }.
117
+ */
118
+ function clearSupervisorRecord(configDir) {
119
+ const file = getAdminFilePath(configDir);
120
+ const existing = readAdminJson({ configDir });
121
+ if (!existing) {
122
+ return { changed: false, file, hadSupervisor: false };
123
+ }
124
+ const hadSupervisor = SUPERVISOR_FIELDS.some((k) => k in existing);
125
+ if (!hadSupervisor) {
126
+ return { changed: false, file, hadSupervisor: false };
127
+ }
128
+ const cleared = { ...existing };
129
+ for (const field of SUPERVISOR_FIELDS) {
130
+ delete cleared[field];
131
+ }
132
+ if (Object.keys(cleared).length === 0) {
133
+ try {
134
+ fs.unlinkSync(file);
135
+ } catch (err) {
136
+ if (err && err.code !== 'ENOENT') throw err;
137
+ }
138
+ return { changed: true, file, hadSupervisor: true };
139
+ }
140
+ const tmp = `${file}.tmp.${process.pid}`;
141
+ const json = `${JSON.stringify(cleared, null, 2)}\n`;
142
+ fs.writeFileSync(tmp, json, { mode: ADMIN_FILE_MODE });
143
+ fs.renameSync(tmp, file);
144
+ fs.chmodSync(file, ADMIN_FILE_MODE);
145
+ return { changed: true, file, hadSupervisor: true };
146
+ }
147
+
148
+ function appendAuditLog(configDir, payload) {
149
+ if (!fs.existsSync(configDir)) {
150
+ fs.mkdirSync(configDir, { recursive: true, mode: 0o700 });
151
+ }
152
+ const file = path.join(configDir, 'audit.log');
153
+ const record = {
154
+ ts: new Date().toISOString(),
155
+ event: 'autopg_uninstall',
156
+ ...payload,
157
+ };
158
+ fs.appendFileSync(file, `${JSON.stringify(record)}\n`, { mode: 0o600 });
159
+ }
160
+
161
+ function emit(level, msg, silent) {
162
+ if (silent) return;
163
+ const stream = level === 'err' ? process.stderr : process.stdout;
164
+ stream.write(`autopg: ${msg}\n`);
165
+ }
166
+
167
+ /**
168
+ * Run the uninstall flow. Returns a numeric exit code.
169
+ *
170
+ * @param {object} [opts]
171
+ * @param {string} [opts.configDir] — override the autopg config dir (used
172
+ * by tests; production code should leave this undefined and let env vars
173
+ * resolve).
174
+ * @param {boolean} [opts.silent] — suppress stdout/stderr writes.
175
+ */
176
+ export function runUninstall(opts = {}) {
177
+ const configDir = opts.configDir || getConfigDir();
178
+ const silent = opts.silent === true;
179
+
180
+ const pm2Available = pm2IsAvailable();
181
+ if (!pm2Available) {
182
+ emit(
183
+ 'err',
184
+ 'pm2 not found in PATH; skipping pm2 teardown (admin.json supervisor record will still be cleared).',
185
+ silent,
186
+ );
187
+ }
188
+
189
+ const pm2Results = TIER_A_PM2_PROCESSES.map((name) => tearDownPm2(name));
190
+
191
+ let supervisorClear;
192
+ try {
193
+ supervisorClear = clearSupervisorRecord(configDir);
194
+ } catch (err) {
195
+ emit('err', `failed to clear supervisor record in admin.json: ${err.message}`, silent);
196
+ return 1;
197
+ }
198
+
199
+ try {
200
+ appendAuditLog(configDir, {
201
+ pm2Available,
202
+ pm2: pm2Results,
203
+ supervisorRecord: {
204
+ changed: supervisorClear.changed,
205
+ hadSupervisor: supervisorClear.hadSupervisor,
206
+ file: supervisorClear.file,
207
+ },
208
+ });
209
+ } catch {
210
+ // Audit must never break uninstall.
211
+ }
212
+
213
+ const removed = pm2Results.filter((r) => r.removed).map((r) => r.name);
214
+ const absent = pm2Results.filter((r) => r.status === 'already-absent').map((r) => r.name);
215
+
216
+ if (removed.length === 0 && !supervisorClear.changed) {
217
+ emit(
218
+ 'out',
219
+ `not registered under pm2 (${TIER_A_PM2_PROCESSES.join(', ')}); nothing to uninstall`,
220
+ silent,
221
+ );
222
+ return 0;
223
+ }
224
+
225
+ if (removed.length > 0) {
226
+ emit(
227
+ 'out',
228
+ `uninstalled pm2 entries: ${removed.join(', ')} (data dir preserved at ${path.join(configDir, 'data')})`,
229
+ silent,
230
+ );
231
+ }
232
+ if (absent.length > 0 && removed.length > 0) {
233
+ emit('out', `(already absent: ${absent.join(', ')})`, silent);
234
+ }
235
+ if (supervisorClear.changed) {
236
+ emit('out', `cleared supervisor record from ${supervisorClear.file}`, silent);
237
+ } else if (removed.length > 0) {
238
+ emit('out', `no supervisor record to clear in ${supervisorClear.file}`, silent);
239
+ }
240
+ return 0;
241
+ }
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
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Canonical pgserve socket-dir resolver.
3
+ *
4
+ * pgserve singleton (v2.4) — `pgserve-singleton-no-proxy` wish, Group 1.
5
+ *
6
+ * Postgres backend listens on a Unix socket inside this directory plus TCP
7
+ * 5432. The directory is also where `pgserve` records its `.s.PGSQL.<port>`
8
+ * socket file so off-the-shelf libpq clients connecting via
9
+ * `psql -h <socketDir>` (no `-p`) succeed against the systemd / freedesktop
10
+ * convention path. CI runners and minimal containers without
11
+ * `$XDG_RUNTIME_DIR` get `/tmp/pgserve` as the documented fallback.
12
+ */
13
+
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+
17
+ export const SOCKET_DIR_NAME = 'pgserve';
18
+ export const SOCKET_DIR_MODE = 0o700;
19
+
20
+ /**
21
+ * Resolve the canonical socket directory.
22
+ *
23
+ * Preferred: `$XDG_RUNTIME_DIR/pgserve` (systemd / freedesktop convention).
24
+ * Fallback: `/tmp/pgserve` (CI runners and minimal containers without XDG).
25
+ *
26
+ * Pure function — does not touch the filesystem. Use `ensureSocketDir()`
27
+ * to create the directory with the correct permissions.
28
+ */
29
+ export function resolveSocketDir() {
30
+ const xdg = process.env.XDG_RUNTIME_DIR;
31
+ const base = xdg && xdg.length > 0 ? xdg : '/tmp';
32
+ return path.join(base, SOCKET_DIR_NAME);
33
+ }
34
+
35
+ /**
36
+ * Ensure the socket directory exists with mode 0700 and is writable.
37
+ *
38
+ * Returns the resolved path. Throws if the directory exists but is not a
39
+ * directory, or if creation fails for any reason other than EEXIST.
40
+ *
41
+ * The mode is enforced via fs.chmodSync after creation — `mkdirSync(mode)`
42
+ * is honored only when the directory does not already exist.
43
+ */
44
+ export function ensureSocketDir(dir = resolveSocketDir()) {
45
+ fs.mkdirSync(dir, { recursive: true, mode: SOCKET_DIR_MODE });
46
+ fs.chmodSync(dir, SOCKET_DIR_MODE);
47
+
48
+ const stat = fs.statSync(dir);
49
+ if (!stat.isDirectory()) {
50
+ throw new Error(
51
+ `pgserve: socket dir path exists but is not a directory: ${dir}`,
52
+ );
53
+ }
54
+
55
+ // Validate writability by touching a sentinel file. Avoids surfacing the
56
+ // real-world failure ("postgres can't bind socket") at the postmaster
57
+ // boot step where the diagnostic is much harder to trace.
58
+ const probe = path.join(dir, `.writable-${process.pid}-${Date.now()}`);
59
+ try {
60
+ fs.writeFileSync(probe, '');
61
+ fs.unlinkSync(probe);
62
+ } catch (err) {
63
+ throw new Error(
64
+ `pgserve: socket dir not writable (${dir}): ${err.message}`,
65
+ );
66
+ }
67
+
68
+ return dir;
69
+ }