pgserve 2.1.3 → 2.2.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 (228) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +105 -1
  3. package/bin/autopg-wrapper.cjs +16 -0
  4. package/bin/pgserve-wrapper.cjs +31 -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 +11 -1
  27. package/src/cli-config.cjs +310 -0
  28. package/src/cli-install.cjs +98 -11
  29. package/src/cli-restart.cjs +228 -0
  30. package/src/cli-ui.cjs +580 -0
  31. package/src/cluster.js +43 -38
  32. package/src/postgres.js +141 -19
  33. package/src/settings-loader.cjs +235 -0
  34. package/src/settings-migrate.cjs +212 -0
  35. package/src/settings-pg-args.cjs +146 -0
  36. package/src/settings-schema.cjs +422 -0
  37. package/src/settings-validator.cjs +416 -0
  38. package/src/settings-writer.cjs +288 -0
  39. package/.claude/context/windows-debug.md +0 -119
  40. package/.genie/AGENTS.md +0 -15
  41. package/.genie/agents/README.md +0 -110
  42. package/.genie/agents/analyze.md +0 -176
  43. package/.genie/agents/forge.md +0 -290
  44. package/.genie/agents/garbage-cleaner.md +0 -324
  45. package/.genie/agents/garbage-collector.md +0 -596
  46. package/.genie/agents/github-issue-gc.md +0 -618
  47. package/.genie/agents/review.md +0 -380
  48. package/.genie/agents/semantic-analyzer/find-duplicates.md +0 -90
  49. package/.genie/agents/semantic-analyzer/find-orphans.md +0 -99
  50. package/.genie/agents/semantic-analyzer.md +0 -101
  51. package/.genie/agents/update.md +0 -182
  52. package/.genie/agents/wish.md +0 -357
  53. package/.genie/brainstorms/pgserve-v2/DESIGN.md +0 -174
  54. package/.genie/code/AGENTS.md +0 -694
  55. package/.genie/code/agents/audit/risk.md +0 -173
  56. package/.genie/code/agents/audit/security.md +0 -189
  57. package/.genie/code/agents/audit.md +0 -145
  58. package/.genie/code/agents/challenge.md +0 -230
  59. package/.genie/code/agents/change-reviewer.md +0 -295
  60. package/.genie/code/agents/code-garbage-collector.md +0 -425
  61. package/.genie/code/agents/code-quality.md +0 -410
  62. package/.genie/code/agents/commit-suggester.md +0 -255
  63. package/.genie/code/agents/commit.md +0 -124
  64. package/.genie/code/agents/consensus.md +0 -204
  65. package/.genie/code/agents/daily-standup.md +0 -722
  66. package/.genie/code/agents/docgen.md +0 -48
  67. package/.genie/code/agents/explore.md +0 -79
  68. package/.genie/code/agents/fix.md +0 -100
  69. package/.genie/code/agents/git/commit-advisory.md +0 -219
  70. package/.genie/code/agents/git/workflows/issue.md +0 -244
  71. package/.genie/code/agents/git/workflows/pr.md +0 -179
  72. package/.genie/code/agents/git/workflows/release.md +0 -460
  73. package/.genie/code/agents/git/workflows/report.md +0 -342
  74. package/.genie/code/agents/git.md +0 -432
  75. package/.genie/code/agents/implementor.md +0 -161
  76. package/.genie/code/agents/install.md +0 -515
  77. package/.genie/code/agents/issue-creator.md +0 -344
  78. package/.genie/code/agents/polish.md +0 -116
  79. package/.genie/code/agents/qa.md +0 -653
  80. package/.genie/code/agents/refactor.md +0 -294
  81. package/.genie/code/agents/release.md +0 -1129
  82. package/.genie/code/agents/roadmap.md +0 -885
  83. package/.genie/code/agents/tests.md +0 -557
  84. package/.genie/code/agents/tracer.md +0 -50
  85. package/.genie/code/agents/update/upstream-update.md +0 -85
  86. package/.genie/code/agents/update/versions/generic-update.md +0 -305
  87. package/.genie/code/agents/vibe.md +0 -1317
  88. package/.genie/code/spells/agent-configuration.md +0 -58
  89. package/.genie/code/spells/automated-rc-publishing.md +0 -106
  90. package/.genie/code/spells/branch-tracker-guidance.md +0 -28
  91. package/.genie/code/spells/debug.md +0 -320
  92. package/.genie/code/spells/emoji-naming-convention.md +0 -303
  93. package/.genie/code/spells/evidence-storage.md +0 -26
  94. package/.genie/code/spells/file-naming-rules.md +0 -35
  95. package/.genie/code/spells/forge-code-blueprints.md +0 -195
  96. package/.genie/code/spells/genie-integration.md +0 -153
  97. package/.genie/code/spells/publishing-protocol.md +0 -61
  98. package/.genie/code/spells/team-consultation-protocol.md +0 -284
  99. package/.genie/code/spells/tool-requirements.md +0 -20
  100. package/.genie/code/spells/triad-maintenance-protocol.md +0 -154
  101. package/.genie/code/teams/tech-council/council.md +0 -328
  102. package/.genie/code/teams/tech-council/jt.md +0 -352
  103. package/.genie/code/teams/tech-council/nayr.md +0 -305
  104. package/.genie/code/teams/tech-council/oettam.md +0 -375
  105. package/.genie/neurons/README.md +0 -193
  106. package/.genie/neurons/forge.md +0 -106
  107. package/.genie/neurons/genie.md +0 -63
  108. package/.genie/neurons/review.md +0 -106
  109. package/.genie/neurons/wish.md +0 -104
  110. package/.genie/product/README.md +0 -20
  111. package/.genie/product/cli-automation.md +0 -359
  112. package/.genie/product/environment.md +0 -60
  113. package/.genie/product/mission.md +0 -60
  114. package/.genie/product/roadmap.md +0 -44
  115. package/.genie/product/tech-stack.md +0 -34
  116. package/.genie/product/templates/context-template.md +0 -218
  117. package/.genie/product/templates/qa-done-report-template.md +0 -68
  118. package/.genie/product/templates/review-report-template.md +0 -89
  119. package/.genie/product/templates/wish-template.md +0 -120
  120. package/.genie/scripts/helpers/analyze-commit.js +0 -195
  121. package/.genie/scripts/helpers/bullet-counter.js +0 -194
  122. package/.genie/scripts/helpers/bullet-find.js +0 -289
  123. package/.genie/scripts/helpers/bullet-id.js +0 -244
  124. package/.genie/scripts/helpers/check-secrets.js +0 -237
  125. package/.genie/scripts/helpers/count-tokens.js +0 -200
  126. package/.genie/scripts/helpers/create-frontmatter.js +0 -456
  127. package/.genie/scripts/helpers/detect-markers.js +0 -293
  128. package/.genie/scripts/helpers/detect-todos.js +0 -267
  129. package/.genie/scripts/helpers/detect-unlabeled-blocks.js +0 -135
  130. package/.genie/scripts/helpers/embeddings.js +0 -344
  131. package/.genie/scripts/helpers/find-empty-sections.js +0 -158
  132. package/.genie/scripts/helpers/index.js +0 -319
  133. package/.genie/scripts/helpers/validate-frontmatter.js +0 -578
  134. package/.genie/scripts/helpers/validate-links.js +0 -207
  135. package/.genie/scripts/helpers/validate-paths.js +0 -373
  136. package/.genie/spells/README.md +0 -9
  137. package/.genie/spells/ace-protocol.md +0 -118
  138. package/.genie/spells/ask-one-at-a-time.md +0 -175
  139. package/.genie/spells/backup-analyzer.md +0 -542
  140. package/.genie/spells/blocker.md +0 -12
  141. package/.genie/spells/break-things-move-fast.md +0 -56
  142. package/.genie/spells/context-candidates.md +0 -72
  143. package/.genie/spells/context-critic.md +0 -51
  144. package/.genie/spells/defer-to-expertise.md +0 -278
  145. package/.genie/spells/delegate-dont-do.md +0 -292
  146. package/.genie/spells/error-investigation-protocol.md +0 -328
  147. package/.genie/spells/evidence-based-completion.md +0 -273
  148. package/.genie/spells/experiment.md +0 -65
  149. package/.genie/spells/file-creation-protocol.md +0 -229
  150. package/.genie/spells/forge-integration.md +0 -281
  151. package/.genie/spells/forge-orchestration.md +0 -514
  152. package/.genie/spells/gather-context.md +0 -18
  153. package/.genie/spells/global-health-check.md +0 -34
  154. package/.genie/spells/global-noop-roundtrip.md +0 -25
  155. package/.genie/spells/install-genie.md +0 -1232
  156. package/.genie/spells/install.md +0 -82
  157. package/.genie/spells/investigate-before-commit.md +0 -112
  158. package/.genie/spells/know-yourself.md +0 -288
  159. package/.genie/spells/learn.md +0 -828
  160. package/.genie/spells/mcp-diagnostic-protocol.md +0 -246
  161. package/.genie/spells/mcp-first.md +0 -124
  162. package/.genie/spells/multi-step-execution.md +0 -67
  163. package/.genie/spells/orchestration-boundary-protocol.md +0 -256
  164. package/.genie/spells/orchestrator-not-implementor.md +0 -189
  165. package/.genie/spells/prompt.md +0 -746
  166. package/.genie/spells/reflect.md +0 -404
  167. package/.genie/spells/routing-decision-matrix.md +0 -368
  168. package/.genie/spells/run-in-parallel.md +0 -12
  169. package/.genie/spells/session-state-updater-example.md +0 -196
  170. package/.genie/spells/session-state-updater.md +0 -220
  171. package/.genie/spells/track-long-running-tasks.md +0 -133
  172. package/.genie/spells/troubleshoot-infrastructure.md +0 -176
  173. package/.genie/spells/upgrade-genie.md +0 -415
  174. package/.genie/spells/url-presentation-protocol.md +0 -301
  175. package/.genie/spells/wish-initiation.md +0 -158
  176. package/.genie/spells/wish-issue-linkage.md +0 -410
  177. package/.genie/spells/wish-lifecycle.md +0 -100
  178. package/.genie/state/provider-status.json +0 -3
  179. package/.genie/state/version.json +0 -16
  180. package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +0 -290
  181. package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +0 -99
  182. package/.genie/wishes/pgserve-v2/WISH.md +0 -442
  183. package/.genie/wishes/release-system-genie-pattern/WISH.md +0 -268
  184. package/.genie/wishes/release-system-genie-pattern/validation.md +0 -205
  185. package/.gitguardian.yaml +0 -29
  186. package/.gitguardianignore +0 -16
  187. package/.github/workflows/ci.yml +0 -122
  188. package/.github/workflows/release.yml +0 -289
  189. package/.github/workflows/version.yml +0 -228
  190. package/.husky/pre-commit +0 -2
  191. package/AGENTS.md +0 -433
  192. package/CLAUDE.md +0 -1
  193. package/Makefile +0 -285
  194. package/assets/icon.ico +0 -0
  195. package/bun.lock +0 -435
  196. package/bunfig.toml +0 -28
  197. package/ecosystem.config.cjs +0 -23
  198. package/eslint.config.js +0 -63
  199. package/examples/multi-tenant-demo.js +0 -104
  200. package/install.sh +0 -123
  201. package/knip.json +0 -9
  202. package/scripts/test-bun-self-heal.sh +0 -163
  203. package/scripts/test-npx.sh +0 -60
  204. package/tests/audit.test.js +0 -189
  205. package/tests/backpressure.test.js +0 -167
  206. package/tests/benchmarks/runner.js +0 -1197
  207. package/tests/benchmarks/vector-generator.js +0 -368
  208. package/tests/cli-install.test.js +0 -322
  209. package/tests/control-db.test.js +0 -285
  210. package/tests/daemon-args.test.js +0 -86
  211. package/tests/daemon-control.test.js +0 -171
  212. package/tests/daemon-fingerprint-integration.test.js +0 -111
  213. package/tests/daemon-pr24-regression.test.js +0 -198
  214. package/tests/fingerprint.test.js +0 -263
  215. package/tests/fixtures/240-orphan-seed.sql +0 -30
  216. package/tests/multi-tenant.test.js +0 -374
  217. package/tests/orphan-cleanup.test.js +0 -390
  218. package/tests/pg-version-regex.test.js +0 -129
  219. package/tests/quick-bench.js +0 -135
  220. package/tests/router-handshake-retry.test.js +0 -119
  221. package/tests/router-handshake-watchdog.test.js +0 -110
  222. package/tests/sdk.test.js +0 -71
  223. package/tests/stale-postmaster-pid.test.js +0 -85
  224. package/tests/stress-test.js +0 -439
  225. package/tests/sync-perf-test.js +0 -150
  226. package/tests/tcp-listen.test.js +0 -368
  227. package/tests/tenancy.test.js +0 -403
  228. 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();