pgserve 2.1.3 → 2.2.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.
Files changed (235) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +105 -1
  3. package/bin/autopg-wrapper.cjs +16 -0
  4. package/bin/pgserve-wrapper.cjs +32 -6
  5. package/bin/postgres-server.js +56 -0
  6. package/console/README.md +131 -0
  7. package/console/api.js +173 -0
  8. package/console/app.jsx +483 -0
  9. package/console/colors_and_type.css +227 -0
  10. package/console/components.jsx +167 -0
  11. package/console/console.css +1666 -0
  12. package/console/data.jsx +350 -0
  13. package/console/index.html +31 -0
  14. package/console/screens/databases.jsx +5 -0
  15. package/console/screens/health.jsx +5 -0
  16. package/console/screens/ingress.jsx +5 -0
  17. package/console/screens/optimizer.jsx +5 -0
  18. package/console/screens/rlm-sim.jsx +5 -0
  19. package/console/screens/rlm-trace.jsx +5 -0
  20. package/console/screens/security.jsx +5 -0
  21. package/console/screens/settings.jsx +611 -0
  22. package/console/screens/sql.jsx +5 -0
  23. package/console/screens/sync.jsx +5 -0
  24. package/console/screens/tables.jsx +5 -0
  25. package/console/tweaks-panel.jsx +425 -0
  26. package/package.json +14 -2
  27. package/scripts/postinstall.cjs +60 -0
  28. package/src/cli-config.cjs +310 -0
  29. package/src/cli-install.cjs +112 -11
  30. package/src/cli-restart.cjs +228 -0
  31. package/src/cli-ui.cjs +580 -0
  32. package/src/cluster.js +43 -38
  33. package/src/postgres.js +141 -19
  34. package/src/settings-loader.cjs +235 -0
  35. package/src/settings-migrate.cjs +212 -0
  36. package/src/settings-pg-args.cjs +146 -0
  37. package/src/settings-schema.cjs +422 -0
  38. package/src/settings-validator.cjs +416 -0
  39. package/src/settings-writer.cjs +288 -0
  40. package/src/upgrade/index.js +65 -0
  41. package/src/upgrade/runner.js +23 -0
  42. package/src/upgrade/steps/binary-cache-flush.js +67 -0
  43. package/src/upgrade/steps/consumer-signal.js +40 -0
  44. package/src/upgrade/steps/env-refresh.js +89 -0
  45. package/src/upgrade/steps/health-validate.js +53 -0
  46. package/src/upgrade/steps/plpgsql-resolve.js +66 -0
  47. package/src/upgrade/steps/port-reconcile.js +52 -0
  48. package/.claude/context/windows-debug.md +0 -119
  49. package/.genie/AGENTS.md +0 -15
  50. package/.genie/agents/README.md +0 -110
  51. package/.genie/agents/analyze.md +0 -176
  52. package/.genie/agents/forge.md +0 -290
  53. package/.genie/agents/garbage-cleaner.md +0 -324
  54. package/.genie/agents/garbage-collector.md +0 -596
  55. package/.genie/agents/github-issue-gc.md +0 -618
  56. package/.genie/agents/review.md +0 -380
  57. package/.genie/agents/semantic-analyzer/find-duplicates.md +0 -90
  58. package/.genie/agents/semantic-analyzer/find-orphans.md +0 -99
  59. package/.genie/agents/semantic-analyzer.md +0 -101
  60. package/.genie/agents/update.md +0 -182
  61. package/.genie/agents/wish.md +0 -357
  62. package/.genie/brainstorms/pgserve-v2/DESIGN.md +0 -174
  63. package/.genie/code/AGENTS.md +0 -694
  64. package/.genie/code/agents/audit/risk.md +0 -173
  65. package/.genie/code/agents/audit/security.md +0 -189
  66. package/.genie/code/agents/audit.md +0 -145
  67. package/.genie/code/agents/challenge.md +0 -230
  68. package/.genie/code/agents/change-reviewer.md +0 -295
  69. package/.genie/code/agents/code-garbage-collector.md +0 -425
  70. package/.genie/code/agents/code-quality.md +0 -410
  71. package/.genie/code/agents/commit-suggester.md +0 -255
  72. package/.genie/code/agents/commit.md +0 -124
  73. package/.genie/code/agents/consensus.md +0 -204
  74. package/.genie/code/agents/daily-standup.md +0 -722
  75. package/.genie/code/agents/docgen.md +0 -48
  76. package/.genie/code/agents/explore.md +0 -79
  77. package/.genie/code/agents/fix.md +0 -100
  78. package/.genie/code/agents/git/commit-advisory.md +0 -219
  79. package/.genie/code/agents/git/workflows/issue.md +0 -244
  80. package/.genie/code/agents/git/workflows/pr.md +0 -179
  81. package/.genie/code/agents/git/workflows/release.md +0 -460
  82. package/.genie/code/agents/git/workflows/report.md +0 -342
  83. package/.genie/code/agents/git.md +0 -432
  84. package/.genie/code/agents/implementor.md +0 -161
  85. package/.genie/code/agents/install.md +0 -515
  86. package/.genie/code/agents/issue-creator.md +0 -344
  87. package/.genie/code/agents/polish.md +0 -116
  88. package/.genie/code/agents/qa.md +0 -653
  89. package/.genie/code/agents/refactor.md +0 -294
  90. package/.genie/code/agents/release.md +0 -1129
  91. package/.genie/code/agents/roadmap.md +0 -885
  92. package/.genie/code/agents/tests.md +0 -557
  93. package/.genie/code/agents/tracer.md +0 -50
  94. package/.genie/code/agents/update/upstream-update.md +0 -85
  95. package/.genie/code/agents/update/versions/generic-update.md +0 -305
  96. package/.genie/code/agents/vibe.md +0 -1317
  97. package/.genie/code/spells/agent-configuration.md +0 -58
  98. package/.genie/code/spells/automated-rc-publishing.md +0 -106
  99. package/.genie/code/spells/branch-tracker-guidance.md +0 -28
  100. package/.genie/code/spells/debug.md +0 -320
  101. package/.genie/code/spells/emoji-naming-convention.md +0 -303
  102. package/.genie/code/spells/evidence-storage.md +0 -26
  103. package/.genie/code/spells/file-naming-rules.md +0 -35
  104. package/.genie/code/spells/forge-code-blueprints.md +0 -195
  105. package/.genie/code/spells/genie-integration.md +0 -153
  106. package/.genie/code/spells/publishing-protocol.md +0 -61
  107. package/.genie/code/spells/team-consultation-protocol.md +0 -284
  108. package/.genie/code/spells/tool-requirements.md +0 -20
  109. package/.genie/code/spells/triad-maintenance-protocol.md +0 -154
  110. package/.genie/code/teams/tech-council/council.md +0 -328
  111. package/.genie/code/teams/tech-council/jt.md +0 -352
  112. package/.genie/code/teams/tech-council/nayr.md +0 -305
  113. package/.genie/code/teams/tech-council/oettam.md +0 -375
  114. package/.genie/neurons/README.md +0 -193
  115. package/.genie/neurons/forge.md +0 -106
  116. package/.genie/neurons/genie.md +0 -63
  117. package/.genie/neurons/review.md +0 -106
  118. package/.genie/neurons/wish.md +0 -104
  119. package/.genie/product/README.md +0 -20
  120. package/.genie/product/cli-automation.md +0 -359
  121. package/.genie/product/environment.md +0 -60
  122. package/.genie/product/mission.md +0 -60
  123. package/.genie/product/roadmap.md +0 -44
  124. package/.genie/product/tech-stack.md +0 -34
  125. package/.genie/product/templates/context-template.md +0 -218
  126. package/.genie/product/templates/qa-done-report-template.md +0 -68
  127. package/.genie/product/templates/review-report-template.md +0 -89
  128. package/.genie/product/templates/wish-template.md +0 -120
  129. package/.genie/scripts/helpers/analyze-commit.js +0 -195
  130. package/.genie/scripts/helpers/bullet-counter.js +0 -194
  131. package/.genie/scripts/helpers/bullet-find.js +0 -289
  132. package/.genie/scripts/helpers/bullet-id.js +0 -244
  133. package/.genie/scripts/helpers/check-secrets.js +0 -237
  134. package/.genie/scripts/helpers/count-tokens.js +0 -200
  135. package/.genie/scripts/helpers/create-frontmatter.js +0 -456
  136. package/.genie/scripts/helpers/detect-markers.js +0 -293
  137. package/.genie/scripts/helpers/detect-todos.js +0 -267
  138. package/.genie/scripts/helpers/detect-unlabeled-blocks.js +0 -135
  139. package/.genie/scripts/helpers/embeddings.js +0 -344
  140. package/.genie/scripts/helpers/find-empty-sections.js +0 -158
  141. package/.genie/scripts/helpers/index.js +0 -319
  142. package/.genie/scripts/helpers/validate-frontmatter.js +0 -578
  143. package/.genie/scripts/helpers/validate-links.js +0 -207
  144. package/.genie/scripts/helpers/validate-paths.js +0 -373
  145. package/.genie/spells/README.md +0 -9
  146. package/.genie/spells/ace-protocol.md +0 -118
  147. package/.genie/spells/ask-one-at-a-time.md +0 -175
  148. package/.genie/spells/backup-analyzer.md +0 -542
  149. package/.genie/spells/blocker.md +0 -12
  150. package/.genie/spells/break-things-move-fast.md +0 -56
  151. package/.genie/spells/context-candidates.md +0 -72
  152. package/.genie/spells/context-critic.md +0 -51
  153. package/.genie/spells/defer-to-expertise.md +0 -278
  154. package/.genie/spells/delegate-dont-do.md +0 -292
  155. package/.genie/spells/error-investigation-protocol.md +0 -328
  156. package/.genie/spells/evidence-based-completion.md +0 -273
  157. package/.genie/spells/experiment.md +0 -65
  158. package/.genie/spells/file-creation-protocol.md +0 -229
  159. package/.genie/spells/forge-integration.md +0 -281
  160. package/.genie/spells/forge-orchestration.md +0 -514
  161. package/.genie/spells/gather-context.md +0 -18
  162. package/.genie/spells/global-health-check.md +0 -34
  163. package/.genie/spells/global-noop-roundtrip.md +0 -25
  164. package/.genie/spells/install-genie.md +0 -1232
  165. package/.genie/spells/install.md +0 -82
  166. package/.genie/spells/investigate-before-commit.md +0 -112
  167. package/.genie/spells/know-yourself.md +0 -288
  168. package/.genie/spells/learn.md +0 -828
  169. package/.genie/spells/mcp-diagnostic-protocol.md +0 -246
  170. package/.genie/spells/mcp-first.md +0 -124
  171. package/.genie/spells/multi-step-execution.md +0 -67
  172. package/.genie/spells/orchestration-boundary-protocol.md +0 -256
  173. package/.genie/spells/orchestrator-not-implementor.md +0 -189
  174. package/.genie/spells/prompt.md +0 -746
  175. package/.genie/spells/reflect.md +0 -404
  176. package/.genie/spells/routing-decision-matrix.md +0 -368
  177. package/.genie/spells/run-in-parallel.md +0 -12
  178. package/.genie/spells/session-state-updater-example.md +0 -196
  179. package/.genie/spells/session-state-updater.md +0 -220
  180. package/.genie/spells/track-long-running-tasks.md +0 -133
  181. package/.genie/spells/troubleshoot-infrastructure.md +0 -176
  182. package/.genie/spells/upgrade-genie.md +0 -415
  183. package/.genie/spells/url-presentation-protocol.md +0 -301
  184. package/.genie/spells/wish-initiation.md +0 -158
  185. package/.genie/spells/wish-issue-linkage.md +0 -410
  186. package/.genie/spells/wish-lifecycle.md +0 -100
  187. package/.genie/state/provider-status.json +0 -3
  188. package/.genie/state/version.json +0 -16
  189. package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +0 -290
  190. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +0 -99
  191. package/.genie/wishes/pgserve-v2/WISH.md +0 -442
  192. package/.genie/wishes/release-system-genie-pattern/WISH.md +0 -268
  193. package/.genie/wishes/release-system-genie-pattern/validation.md +0 -205
  194. package/.gitguardian.yaml +0 -29
  195. package/.gitguardianignore +0 -16
  196. package/.github/workflows/ci.yml +0 -122
  197. package/.github/workflows/release.yml +0 -289
  198. package/.github/workflows/version.yml +0 -228
  199. package/.husky/pre-commit +0 -2
  200. package/AGENTS.md +0 -433
  201. package/CLAUDE.md +0 -1
  202. package/Makefile +0 -285
  203. package/assets/icon.ico +0 -0
  204. package/bun.lock +0 -435
  205. package/bunfig.toml +0 -28
  206. package/ecosystem.config.cjs +0 -23
  207. package/eslint.config.js +0 -63
  208. package/examples/multi-tenant-demo.js +0 -104
  209. package/install.sh +0 -123
  210. package/knip.json +0 -9
  211. package/tests/audit.test.js +0 -189
  212. package/tests/backpressure.test.js +0 -167
  213. package/tests/benchmarks/runner.js +0 -1197
  214. package/tests/benchmarks/vector-generator.js +0 -368
  215. package/tests/cli-install.test.js +0 -322
  216. package/tests/control-db.test.js +0 -285
  217. package/tests/daemon-args.test.js +0 -86
  218. package/tests/daemon-control.test.js +0 -171
  219. package/tests/daemon-fingerprint-integration.test.js +0 -111
  220. package/tests/daemon-pr24-regression.test.js +0 -198
  221. package/tests/fingerprint.test.js +0 -263
  222. package/tests/fixtures/240-orphan-seed.sql +0 -30
  223. package/tests/multi-tenant.test.js +0 -374
  224. package/tests/orphan-cleanup.test.js +0 -390
  225. package/tests/pg-version-regex.test.js +0 -129
  226. package/tests/quick-bench.js +0 -135
  227. package/tests/router-handshake-retry.test.js +0 -119
  228. package/tests/router-handshake-watchdog.test.js +0 -110
  229. package/tests/sdk.test.js +0 -71
  230. package/tests/stale-postmaster-pid.test.js +0 -85
  231. package/tests/stress-test.js +0 -439
  232. package/tests/sync-perf-test.js +0 -150
  233. package/tests/tcp-listen.test.js +0 -368
  234. package/tests/tenancy.test.js +0 -403
  235. package/tests/wrapper-supervision.test.js +0 -107
package/src/cli-ui.cjs ADDED
@@ -0,0 +1,580 @@
1
+ /**
2
+ * `autopg ui [--port N] [--no-open]` (also reachable via `pgserve ui`).
3
+ *
4
+ * Boots a tiny http server bound to 127.0.0.1 that:
5
+ * - serves the static console at `console/` (React + Babel CDN, no build).
6
+ * - exposes 4 helper endpoints used by the SPA:
7
+ * GET /api/settings → { settings, sources, etag }
8
+ * PUT /api/settings → writeSettings + If-Match etag check
9
+ * POST /api/restart → invokes cli-restart.dispatch
10
+ * GET /api/status → shells out to the existing wave-1 status
11
+ *
12
+ * Single-user dev tool: 127.0.0.1 only, no auth, no TLS. Designed to ride
13
+ * inside an operator's localhost session — not to be exposed.
14
+ *
15
+ * Port selection:
16
+ * --port N → bind exactly N or fail.
17
+ * (no flag) → walk 8433..8533 picking the first free port.
18
+ *
19
+ * Browser opening:
20
+ * --no-open → skip browser launch (CI/headless paths).
21
+ * default → `open` (macOS) / `xdg-open` (Linux) / `start` (Windows).
22
+ */
23
+
24
+ 'use strict';
25
+
26
+ const http = require('node:http');
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+ const { spawn, execFileSync } = require('node:child_process');
30
+
31
+ const { loadEffectiveConfig, getSettingsPath } = require('./settings-loader.cjs');
32
+ const { writeSettings } = require('./settings-writer.cjs');
33
+ const cliRestart = require('./cli-restart.cjs');
34
+
35
+ // `pg` is a devDependency today — graceful degradation if not installed.
36
+ // /api/stats returns { ok: false, reason: 'pg-not-installed' } in that case.
37
+ let PgClient = null;
38
+ try { PgClient = require('pg').Client; } catch { /* optional */ }
39
+ const {
40
+ ValidationError,
41
+ EtagMismatchError,
42
+ ERROR_CODES,
43
+ } = require('./settings-validator.cjs');
44
+
45
+ const PORT_RANGE_START = 8433;
46
+ const PORT_RANGE_END = 8533;
47
+ const HOST = '127.0.0.1';
48
+
49
+ const MIME_TYPES = {
50
+ '.html': 'text/html; charset=utf-8',
51
+ '.htm': 'text/html; charset=utf-8',
52
+ '.js': 'application/javascript; charset=utf-8',
53
+ '.mjs': 'application/javascript; charset=utf-8',
54
+ '.jsx': 'application/javascript; charset=utf-8',
55
+ '.cjs': 'application/javascript; charset=utf-8',
56
+ '.css': 'text/css; charset=utf-8',
57
+ '.json': 'application/json; charset=utf-8',
58
+ '.svg': 'image/svg+xml',
59
+ '.png': 'image/png',
60
+ '.jpg': 'image/jpeg',
61
+ '.jpeg': 'image/jpeg',
62
+ '.gif': 'image/gif',
63
+ '.ico': 'image/x-icon',
64
+ '.txt': 'text/plain; charset=utf-8',
65
+ '.woff': 'font/woff',
66
+ '.woff2': 'font/woff2',
67
+ };
68
+
69
+ function parseArgs(args) {
70
+ const out = { port: null, noOpen: false, host: HOST };
71
+ for (let i = 0; i < args.length; i++) {
72
+ const a = args[i];
73
+ if (a === '--port') {
74
+ const v = args[++i];
75
+ const n = Number.parseInt(v, 10);
76
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
77
+ throw new Error(`invalid --port "${v}"`);
78
+ }
79
+ out.port = n;
80
+ } else if (a === '--no-open') {
81
+ out.noOpen = true;
82
+ } else if (a === '--host') {
83
+ // Defense: still bind 127.0.0.1 unless explicitly opted out via env.
84
+ // We accept --host for parity but ignore non-loopback values.
85
+ const v = args[++i];
86
+ if (v === '127.0.0.1' || v === 'localhost') {
87
+ out.host = v;
88
+ }
89
+ }
90
+ }
91
+ return out;
92
+ }
93
+
94
+ /**
95
+ * Try to bind a server on each candidate port until one succeeds.
96
+ * Returns a Promise<{server, port}>. Rejects if no port in the range works.
97
+ */
98
+ function listenWithFallback(server, host, preferredPort) {
99
+ const candidates = preferredPort
100
+ ? [preferredPort]
101
+ : (() => {
102
+ const list = [];
103
+ for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) list.push(p);
104
+ return list;
105
+ })();
106
+
107
+ return new Promise((resolve, reject) => {
108
+ let i = 0;
109
+ function attempt() {
110
+ if (i >= candidates.length) {
111
+ reject(
112
+ new Error(
113
+ preferredPort
114
+ ? `port ${preferredPort} is not available`
115
+ : `no free port in ${PORT_RANGE_START}-${PORT_RANGE_END}`,
116
+ ),
117
+ );
118
+ return;
119
+ }
120
+ const port = candidates[i++];
121
+ const onErr = (err) => {
122
+ if (err.code === 'EADDRINUSE' && !preferredPort) {
123
+ server.removeListener('error', onErr);
124
+ attempt();
125
+ return;
126
+ }
127
+ reject(err);
128
+ };
129
+ server.once('error', onErr);
130
+ server.listen(port, host, () => {
131
+ server.removeListener('error', onErr);
132
+ resolve({ server, port });
133
+ });
134
+ }
135
+ attempt();
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Resolve the static document root. The console directory lives at the
141
+ * repo root (alongside `bin/` and `src/`). When the package is installed
142
+ * via npm the `files` allowlist preserves the layout.
143
+ */
144
+ function resolveConsoleRoot() {
145
+ // src/ → repo root → console/
146
+ return path.resolve(__dirname, '..', 'console');
147
+ }
148
+
149
+ /**
150
+ * Sanitize a request path against directory traversal, return the absolute
151
+ * file path on disk or null if the request escapes the document root.
152
+ */
153
+ function safeJoin(root, urlPath) {
154
+ // Strip query string defensively even though the caller already removed it.
155
+ const clean = urlPath.split('?')[0];
156
+ // Normalize then refuse anything starting with `..` or absolute outside
157
+ // the root.
158
+ const decoded = decodeURIComponent(clean);
159
+ const normalized = path.posix.normalize(decoded).replace(/^\/+/, '');
160
+ const candidate = path.resolve(root, normalized);
161
+ if (!candidate.startsWith(`${root}${path.sep}`) && candidate !== root) {
162
+ return null;
163
+ }
164
+ return candidate;
165
+ }
166
+
167
+ function sendJson(res, status, payload) {
168
+ const body = JSON.stringify(payload);
169
+ res.writeHead(status, {
170
+ 'content-type': 'application/json; charset=utf-8',
171
+ 'content-length': Buffer.byteLength(body),
172
+ 'cache-control': 'no-store',
173
+ });
174
+ res.end(body);
175
+ }
176
+
177
+ function sendError(res, status, code, message, extra = {}) {
178
+ sendJson(res, status, { error: { code, message, ...extra } });
179
+ }
180
+
181
+ function readBody(req, { limitBytes = 1_048_576 } = {}) {
182
+ return new Promise((resolve, reject) => {
183
+ const chunks = [];
184
+ let total = 0;
185
+ req.on('data', (chunk) => {
186
+ total += chunk.length;
187
+ if (total > limitBytes) {
188
+ reject(new Error('request body too large'));
189
+ req.destroy();
190
+ return;
191
+ }
192
+ chunks.push(chunk);
193
+ });
194
+ req.on('end', () => {
195
+ try {
196
+ const raw = Buffer.concat(chunks).toString('utf8');
197
+ if (!raw) return resolve({});
198
+ resolve(JSON.parse(raw));
199
+ } catch (err) {
200
+ reject(err);
201
+ }
202
+ });
203
+ req.on('error', reject);
204
+ });
205
+ }
206
+
207
+ // ─── handlers ────────────────────────────────────────────────────────────
208
+
209
+ function handleGetSettings(req, res) {
210
+ try {
211
+ const { settings, sources, etag, path: settingsPath } = loadEffectiveConfig();
212
+ sendJson(res, 200, { settings, sources, etag, path: settingsPath });
213
+ } catch (err) {
214
+ sendError(res, 500, 'LOAD_FAILED', err.message ?? String(err));
215
+ }
216
+ }
217
+
218
+ async function handlePutSettings(req, res) {
219
+ let body;
220
+ try {
221
+ body = await readBody(req);
222
+ } catch (err) {
223
+ sendError(res, 400, 'BAD_BODY', err.message ?? 'invalid JSON');
224
+ return;
225
+ }
226
+ const ifMatch = req.headers['if-match'];
227
+ if (!ifMatch) {
228
+ sendError(res, 428, 'PRECONDITION_REQUIRED', 'If-Match header required');
229
+ return;
230
+ }
231
+ try {
232
+ // Merge the patch onto the current effective tree before writing so
233
+ // partial PUTs only touch the supplied keys. The writer re-validates.
234
+ const { settings: current } = loadEffectiveConfig();
235
+ const merged = deepMergePlain(current, body);
236
+ const { etag } = writeSettings(merged, { ifMatch });
237
+ sendJson(res, 200, { ok: true, etag });
238
+ } catch (err) {
239
+ if (err instanceof EtagMismatchError) {
240
+ sendJson(res, 409, {
241
+ error: {
242
+ code: ERROR_CODES.ETAG_MISMATCH,
243
+ message: 'settings changed on disk; reload before retry',
244
+ },
245
+ currentEtag: err.currentEtag,
246
+ });
247
+ return;
248
+ }
249
+ if (err instanceof ValidationError) {
250
+ sendError(res, 400, err.code, err.detail ?? err.message, { field: err.field });
251
+ return;
252
+ }
253
+ sendError(res, 500, 'WRITE_FAILED', err.message ?? String(err));
254
+ }
255
+ }
256
+
257
+ function deepMergePlain(base, patch) {
258
+ if (!patch || typeof patch !== 'object' || Array.isArray(patch)) return base;
259
+ const out = base && typeof base === 'object' && !Array.isArray(base) ? { ...base } : {};
260
+ for (const [key, value] of Object.entries(patch)) {
261
+ if (
262
+ value &&
263
+ typeof value === 'object' &&
264
+ !Array.isArray(value) &&
265
+ out[key] &&
266
+ typeof out[key] === 'object' &&
267
+ !Array.isArray(out[key])
268
+ ) {
269
+ out[key] = deepMergePlain(out[key], value);
270
+ } else {
271
+ out[key] = value;
272
+ }
273
+ }
274
+ return out;
275
+ }
276
+
277
+ function handlePostRestart(req, res, ctx) {
278
+ try {
279
+ const code = cliRestart.dispatch([], { scriptPath: ctx.scriptPath });
280
+ if (code === 0) {
281
+ sendJson(res, 200, { ok: true });
282
+ } else {
283
+ sendError(res, 500, 'RESTART_FAILED', `restart exited with code ${code}`);
284
+ }
285
+ } catch (err) {
286
+ sendError(res, 500, 'RESTART_FAILED', err.message ?? String(err));
287
+ }
288
+ }
289
+
290
+ function handleGetStatus(req, res, ctx) {
291
+ // The existing wave-1 `status --json` flow returns the canonical shape.
292
+ // Shell out via the wrapper so the response mirrors what an operator
293
+ // would see at the CLI.
294
+ try {
295
+ if (ctx.statusOverride) {
296
+ sendJson(res, 200, ctx.statusOverride());
297
+ return;
298
+ }
299
+ const out = execFileSync(process.execPath, [ctx.scriptPath, 'status', '--json'], {
300
+ encoding: 'utf8',
301
+ timeout: 5000,
302
+ stdio: ['ignore', 'pipe', 'pipe'],
303
+ });
304
+ const trimmed = out.trim();
305
+ sendJson(res, 200, trimmed ? JSON.parse(trimmed) : {});
306
+ } catch (err) {
307
+ // `pgserve status` exits 1 when not installed but still prints JSON.
308
+ // Surface the parsed payload when present; otherwise wrap the error.
309
+ const stdout = err?.stdout ? err.stdout.toString().trim() : '';
310
+ if (stdout) {
311
+ try {
312
+ sendJson(res, 200, JSON.parse(stdout));
313
+ return;
314
+ } catch {
315
+ // fall through
316
+ }
317
+ }
318
+ sendError(res, 500, 'STATUS_FAILED', err.message ?? String(err));
319
+ }
320
+ }
321
+
322
+ // Cached package.json — read once at module load so we never block on disk.
323
+ const PKG_VERSION = (() => {
324
+ try { return require('../package.json').version; } catch { return 'unknown'; }
325
+ })();
326
+
327
+ async function handleGetStats(req, res) {
328
+ if (!PgClient) {
329
+ sendJson(res, 200, {
330
+ ok: false,
331
+ reason: 'pg-not-installed',
332
+ autopg: { version: PKG_VERSION },
333
+ });
334
+ return;
335
+ }
336
+ let client;
337
+ try {
338
+ const { settings } = loadEffectiveConfig();
339
+ const server = settings.server || {};
340
+ client = new PgClient({
341
+ host: server.host || '127.0.0.1',
342
+ port: server.port || 8432,
343
+ database: 'postgres',
344
+ user: server.pgUser || 'postgres',
345
+ password: server.pgPassword || 'postgres',
346
+ connectionTimeoutMillis: 1500,
347
+ query_timeout: 1500,
348
+ });
349
+ client.on('error', () => {}); // never crash the helper on PG hiccup
350
+ await client.connect();
351
+ // Single round-trip query covering everything the footer needs.
352
+ // pg_stat_activity gives client connections; pg_database the user-db
353
+ // count + total size; pg_stat_database the cache + xact aggregates;
354
+ // pg_settings for the short server_version (no parsing); and
355
+ // pg_postmaster_start_time for uptime.
356
+ const { rows: [row] } = await client.query(`
357
+ SELECT
358
+ (SELECT count(*)::int FROM pg_stat_activity
359
+ WHERE backend_type = 'client backend' AND pid <> pg_backend_pid()) AS connections,
360
+ (SELECT count(*)::int FROM pg_database
361
+ WHERE NOT datistemplate AND datname <> 'postgres') AS databases,
362
+ (SELECT setting FROM pg_settings WHERE name = 'server_version') AS pg_version,
363
+ EXTRACT(epoch FROM (now() - pg_postmaster_start_time()))::int AS uptime_sec,
364
+ (SELECT round(
365
+ 100.0 * sum(blks_hit)::numeric
366
+ / nullif(sum(blks_hit) + sum(blks_read), 0),
367
+ 2
368
+ )::float FROM pg_stat_database) AS cache_hit_pct,
369
+ (SELECT sum(xact_commit + xact_rollback)::bigint
370
+ FROM pg_stat_database) AS tx_total,
371
+ (SELECT sum(pg_database_size(datname))::bigint
372
+ FROM pg_database WHERE NOT datistemplate) AS size_bytes
373
+ `);
374
+ sendJson(res, 200, {
375
+ ok: true,
376
+ connections: row.connections,
377
+ databases: row.databases,
378
+ port: server.port || 8432,
379
+ pg: {
380
+ version: row.pg_version,
381
+ uptimeSec: row.uptime_sec,
382
+ cacheHitPct: row.cache_hit_pct,
383
+ txTotal: Number(row.tx_total ?? 0),
384
+ sizeBytes: Number(row.size_bytes ?? 0),
385
+ },
386
+ autopg: { version: PKG_VERSION },
387
+ ts: Date.now(),
388
+ });
389
+ } catch (err) {
390
+ sendJson(res, 200, {
391
+ ok: false,
392
+ reason: err.code || 'disconnected',
393
+ message: err.message,
394
+ autopg: { version: PKG_VERSION },
395
+ });
396
+ } finally {
397
+ if (client) {
398
+ try { await client.end(); } catch { /* already closed */ }
399
+ }
400
+ }
401
+ }
402
+
403
+ function handleStatic(req, res, root) {
404
+ let url = req.url.split('?')[0];
405
+ if (url === '/' || url === '') url = '/index.html';
406
+ const target = safeJoin(root, url);
407
+ if (!target) {
408
+ sendError(res, 400, 'BAD_PATH', 'invalid path');
409
+ return;
410
+ }
411
+ fs.stat(target, (statErr, stat) => {
412
+ if (statErr || !stat.isFile()) {
413
+ // SPA fallback: serve index.html on a miss so client routing works.
414
+ const fallback = path.join(root, 'index.html');
415
+ if (fs.existsSync(fallback)) {
416
+ serveFile(res, fallback);
417
+ return;
418
+ }
419
+ sendError(res, 404, 'NOT_FOUND', `no file at ${url}`);
420
+ return;
421
+ }
422
+ serveFile(res, target);
423
+ });
424
+ }
425
+
426
+ function serveFile(res, filePath) {
427
+ const ext = path.extname(filePath).toLowerCase();
428
+ const mime = MIME_TYPES[ext] || 'application/octet-stream';
429
+ fs.readFile(filePath, (err, data) => {
430
+ if (err) {
431
+ sendError(res, 500, 'READ_FAILED', err.message);
432
+ return;
433
+ }
434
+ res.writeHead(200, {
435
+ 'content-type': mime,
436
+ 'content-length': data.length,
437
+ 'cache-control': 'no-cache',
438
+ });
439
+ res.end(data);
440
+ });
441
+ }
442
+
443
+ /**
444
+ * Build the request handler. `ctx.scriptPath` is the absolute path to
445
+ * `bin/pgserve-wrapper.cjs` (used for shell-outs). `ctx.consoleRoot`
446
+ * defaults to the repo's `console/` directory.
447
+ */
448
+ function createHandler(ctx = {}) {
449
+ const consoleRoot = ctx.consoleRoot || resolveConsoleRoot();
450
+ return function handler(req, res) {
451
+ const url = req.url || '/';
452
+ const method = req.method || 'GET';
453
+
454
+ if (url.startsWith('/api/')) {
455
+ if (url === '/api/settings' && method === 'GET') return handleGetSettings(req, res);
456
+ if (url === '/api/settings' && method === 'PUT') return handlePutSettings(req, res);
457
+ if (url === '/api/restart' && method === 'POST') return handlePostRestart(req, res, ctx);
458
+ if (url === '/api/status' && method === 'GET') return handleGetStatus(req, res, ctx);
459
+ if (url === '/api/stats' && method === 'GET') return handleGetStats(req, res);
460
+ sendError(res, 404, 'NOT_FOUND', `${method} ${url}`);
461
+ return;
462
+ }
463
+
464
+ // Non-API → static file, GET/HEAD only.
465
+ if (method !== 'GET' && method !== 'HEAD') {
466
+ res.writeHead(405, { allow: 'GET, HEAD' });
467
+ res.end();
468
+ return;
469
+ }
470
+ handleStatic(req, res, consoleRoot);
471
+ };
472
+ }
473
+
474
+ /**
475
+ * Open a URL in the user's default browser. Best-effort: a failure is
476
+ * logged and the server keeps running. Operators can always copy the
477
+ * URL out of the boot banner.
478
+ */
479
+ function openBrowser(url) {
480
+ let cmd;
481
+ let args;
482
+ if (process.platform === 'darwin') {
483
+ cmd = 'open';
484
+ args = [url];
485
+ } else if (process.platform === 'win32') {
486
+ cmd = 'cmd';
487
+ args = ['/c', 'start', '""', url];
488
+ } else {
489
+ cmd = 'xdg-open';
490
+ args = [url];
491
+ }
492
+ try {
493
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
494
+ child.on('error', () => {
495
+ process.stderr.write(`autopg: could not auto-open browser; visit ${url}\n`);
496
+ });
497
+ child.unref();
498
+ } catch {
499
+ process.stderr.write(`autopg: could not auto-open browser; visit ${url}\n`);
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Boot the UI server. Resolves to `{ server, port, close }` so callers
505
+ * (and tests) can shut it down deterministically.
506
+ *
507
+ * In CLI mode, callers should pass `wireSignals: true` so SIGINT/SIGTERM
508
+ * stop the server cleanly and the process exits 0.
509
+ */
510
+ async function startServer({ args = [], scriptPath, consoleRoot, wireSignals = false, openInBrowser = openBrowser } = {}) {
511
+ const opts = parseArgs(args);
512
+ const handler = createHandler({ scriptPath, consoleRoot });
513
+ const server = http.createServer(handler);
514
+ const { port } = await listenWithFallback(server, opts.host, opts.port);
515
+
516
+ const url = `http://${opts.host}:${port}`;
517
+ process.stdout.write(`autopg ui: listening on ${url}\n`);
518
+ process.stdout.write(`autopg ui: settings file is ${getSettingsPath()}\n`);
519
+
520
+ if (!opts.noOpen) {
521
+ openInBrowser(url);
522
+ }
523
+
524
+ function close() {
525
+ return new Promise((resolve) => server.close(() => resolve()));
526
+ }
527
+
528
+ if (wireSignals) {
529
+ const stop = async (sig) => {
530
+ process.stdout.write(`\nautopg ui: ${sig} received, shutting down\n`);
531
+ await close();
532
+ process.exit(0);
533
+ };
534
+ process.once('SIGINT', () => stop('SIGINT'));
535
+ process.once('SIGTERM', () => stop('SIGTERM'));
536
+ }
537
+
538
+ return { server, port, url, close };
539
+ }
540
+
541
+ /**
542
+ * CLI dispatch entry. Boots the server and parks until SIGINT/SIGTERM.
543
+ * Always returns 0 — the signal handlers exit the process directly.
544
+ */
545
+ async function dispatch(args = [], ctx = {}) {
546
+ try {
547
+ await startServer({
548
+ args,
549
+ scriptPath: ctx.scriptPath,
550
+ consoleRoot: ctx.consoleRoot,
551
+ wireSignals: true,
552
+ });
553
+ } catch (err) {
554
+ process.stderr.write(`autopg ui: ${err.message ?? err}\n`);
555
+ return 1;
556
+ }
557
+ // Park forever — signal handlers terminate the process.
558
+ return new Promise(() => {});
559
+ }
560
+
561
+ module.exports = {
562
+ dispatch,
563
+ startServer,
564
+ createHandler,
565
+ parseArgs,
566
+ resolveConsoleRoot,
567
+ // Test surface
568
+ _internals: {
569
+ listenWithFallback,
570
+ safeJoin,
571
+ deepMergePlain,
572
+ handleGetSettings,
573
+ handlePutSettings,
574
+ handlePostRestart,
575
+ handleGetStatus,
576
+ openBrowser,
577
+ PORT_RANGE_START,
578
+ PORT_RANGE_END,
579
+ },
580
+ };
package/src/cluster.js CHANGED
@@ -16,6 +16,7 @@ import { createLogger } from './logger.js';
16
16
  import { PostgresManager } from './postgres.js';
17
17
  import { extractDatabaseName } from './protocol.js';
18
18
  import { EventEmitter } from 'events';
19
+ import { loadEffectiveConfig } from './settings-loader.cjs';
19
20
 
20
21
  // PostgreSQL protocol constants
21
22
  const PROTOCOL_VERSION_3 = 196608;
@@ -427,21 +428,31 @@ export async function startClusterServer(options = {}) {
427
428
  const workers = new Map();
428
429
  const workerStats = new Map(); // Track stats from each worker
429
430
 
430
- // Fork workers with PostgreSQL connection info
431
+ // Fork workers with PostgreSQL connection info.
432
+ //
433
+ // Pass through using AUTOPG_<X> (the primary names) so workers don't
434
+ // emit the legacy-PGSERVE deprecation log for our own internal IPC.
435
+ // PGSERVE_WORKER stays as-is — it's an internal flag, not part of the
436
+ // settings precedence chain.
437
+ const workerEnv = () => ({
438
+ PGSERVE_WORKER: 'true',
439
+ AUTOPG_PORT: String(port),
440
+ AUTOPG_HOST: host,
441
+ AUTOPG_PG_SOCKET: pgSocketPath || '',
442
+ AUTOPG_PG_PORT: String(pgPort),
443
+ AUTOPG_PG_USER: 'postgres',
444
+ AUTOPG_PG_PASSWORD: 'postgres',
445
+ AUTOPG_LOG_LEVEL: options.logLevel || 'info',
446
+ AUTOPG_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false',
447
+ // max_connections is a postgres GUC, not a server-level env. Workers
448
+ // read it from settings.postgres via loadEffectiveConfig; we still
449
+ // ship the value here so a CLI override (e.g. `--max-connections`)
450
+ // reaches the worker before the daemon writes settings.json.
451
+ AUTOPG_MAX_CONNECTIONS: String(options.maxConnections || 1000),
452
+ AUTOPG_ENABLE_PGVECTOR: options.enablePgvector ? 'true' : 'false',
453
+ });
431
454
  for (let i = 0; i < numWorkers; i++) {
432
- const worker = cluster.fork({
433
- PGSERVE_WORKER: 'true',
434
- PGSERVE_PORT: String(port),
435
- PGSERVE_HOST: host,
436
- PGSERVE_PG_SOCKET: pgSocketPath || '',
437
- PGSERVE_PG_PORT: String(pgPort),
438
- PGSERVE_PG_USER: 'postgres',
439
- PGSERVE_PG_PASSWORD: 'postgres',
440
- PGSERVE_LOG_LEVEL: options.logLevel || 'info',
441
- PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false',
442
- PGSERVE_MAX_CONNECTIONS: String(options.maxConnections || 1000),
443
- PGSERVE_ENABLE_PGVECTOR: options.enablePgvector ? 'true' : 'false'
444
- });
455
+ const worker = cluster.fork(workerEnv());
445
456
  workers.set(worker.id, worker);
446
457
  }
447
458
 
@@ -457,19 +468,7 @@ export async function startClusterServer(options = {}) {
457
468
  }
458
469
 
459
470
  console.log(`[pgserve] Worker ${worker.id} died (${signal || code}), restarting...`);
460
- const newWorker = cluster.fork({
461
- PGSERVE_WORKER: 'true',
462
- PGSERVE_PORT: String(port),
463
- PGSERVE_HOST: host,
464
- PGSERVE_PG_SOCKET: pgSocketPath || '',
465
- PGSERVE_PG_PORT: String(pgPort),
466
- PGSERVE_PG_USER: 'postgres',
467
- PGSERVE_PG_PASSWORD: 'postgres',
468
- PGSERVE_LOG_LEVEL: options.logLevel || 'info',
469
- PGSERVE_AUTO_PROVISION: options.autoProvision !== false ? 'true' : 'false',
470
- PGSERVE_MAX_CONNECTIONS: String(options.maxConnections || 1000),
471
- PGSERVE_ENABLE_PGVECTOR: options.enablePgvector ? 'true' : 'false'
472
- });
471
+ const newWorker = cluster.fork(workerEnv());
473
472
  workers.set(newWorker.id, newWorker);
474
473
  });
475
474
 
@@ -545,18 +544,24 @@ export async function startClusterServer(options = {}) {
545
544
  pgManager
546
545
  };
547
546
  } else {
548
- // WORKER: Only run TCP routing, connect to PRIMARY's PostgreSQL
547
+ // WORKER: Only run TCP routing, connect to PRIMARY's PostgreSQL.
548
+ //
549
+ // Worker config comes from loadEffectiveConfig() (defaults < file < env).
550
+ // The primary fork() above sets PGSERVE_* env vars so existing supervised
551
+ // installs keep working; AUTOPG_* env vars take precedence when set, and
552
+ // a one-time deprecation note is emitted for legacy-only PGSERVE_* hits.
553
+ const { settings } = loadEffectiveConfig();
549
554
  const router = new ClusterRouter({
550
- port: parseInt(process.env.PGSERVE_PORT) || 8432,
551
- host: process.env.PGSERVE_HOST || '127.0.0.1',
552
- pgSocketPath: process.env.PGSERVE_PG_SOCKET || null,
553
- pgPort: parseInt(process.env.PGSERVE_PG_PORT) || 6432,
554
- pgUser: process.env.PGSERVE_PG_USER || 'postgres',
555
- pgPassword: process.env.PGSERVE_PG_PASSWORD || 'postgres',
556
- logLevel: process.env.PGSERVE_LOG_LEVEL || 'info',
557
- autoProvision: process.env.PGSERVE_AUTO_PROVISION === 'true',
558
- maxConnections: parseInt(process.env.PGSERVE_MAX_CONNECTIONS) || 1000,
559
- enablePgvector: process.env.PGSERVE_ENABLE_PGVECTOR === 'true'
555
+ port: settings.server.port,
556
+ host: settings.server.host,
557
+ pgSocketPath: settings.server.pgSocketPath || null,
558
+ pgPort: settings.server.pgPort,
559
+ pgUser: settings.server.pgUser,
560
+ pgPassword: settings.server.pgPassword,
561
+ logLevel: settings.runtime.logLevel,
562
+ autoProvision: settings.runtime.autoProvision,
563
+ maxConnections: settings.postgres.max_connections,
564
+ enablePgvector: settings.runtime.enablePgvector,
560
565
  });
561
566
 
562
567
  await router.start();