pgserve 2.0.8 → 2.1.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.
- package/.genie/wishes/canonical-pgserve-pm2-supervision/WISH.md +290 -0
- package/README.md +38 -2
- package/bin/pgserve-wrapper.cjs +33 -2
- package/bin/postgres-server.js +8 -1
- package/package.json +1 -1
- package/src/cli-install.cjs +412 -0
- package/tests/cli-install.test.js +301 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# Wish: Canonical pgserve + PM2 supervision across genie/omni/pgserve
|
|
2
|
+
|
|
3
|
+
| Field | Value |
|
|
4
|
+
|-------|-------|
|
|
5
|
+
| **Status** | DRAFT |
|
|
6
|
+
| **Slug** | `canonical-pgserve-pm2-supervision` |
|
|
7
|
+
| **Date** | 2026-04-30 |
|
|
8
|
+
| **Author** | genie-configure |
|
|
9
|
+
| **Appetite** | medium-large |
|
|
10
|
+
| **Repos touched** | `namastexlabs/pgserve`, `automagik-dev/omni`, `automagik-dev/genie`, `namastexlabs/genie-configure` (brain only) |
|
|
11
|
+
| **Design** | _No brainstorm — direct wish from operational pain (live debugging session 2026-04-30)_ |
|
|
12
|
+
|
|
13
|
+
## Summary
|
|
14
|
+
|
|
15
|
+
Canonicalize **pgserve as the single, central, pm2-supervised database server** that every service in the stack connects to. Make `genie serve` and `omni-api`/`omni-nats` peer-equal pm2 services that boot under the same hardening, register via their own `*-install` commands, and consume pgserve through its CLI.
|
|
16
|
+
|
|
17
|
+
**End-state pm2 list:**
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
┌──────────────────────────────────────────┐
|
|
21
|
+
│ pm2 supervisor │
|
|
22
|
+
├──────────────────────────────────────────┤
|
|
23
|
+
│ 1. pgserve ← NEW (canonical PG) │
|
|
24
|
+
│ 2. omni-api ← existing, reconfig │
|
|
25
|
+
│ 3. omni-nats ← existing │
|
|
26
|
+
│ 4. genie-serve ← NEW │
|
|
27
|
+
│ │
|
|
28
|
+
│ + pm2-logrotate (module, already there) │
|
|
29
|
+
└──────────────────────────────────────────┘
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Trigger
|
|
33
|
+
|
|
34
|
+
Live debugging session, 2026-04-30:
|
|
35
|
+
|
|
36
|
+
1. WhatsApp DM lands at omni-api ✅
|
|
37
|
+
2. omni dispatches to NATS ✅
|
|
38
|
+
3. **bridge silently dropped — `genie serve` was running in a foreground bash on `/dev/pts/24` and died when the shell closed** ❌
|
|
39
|
+
4. Operator (Felipe) sent multiple test messages; nothing came back. Recovery required SSH into the server, kill the orphan, re-launch `genie serve` manually.
|
|
40
|
+
|
|
41
|
+
Earlier in the same session: omni-api was hardened with pm2 + log rotation as part of `omni-lifecycle-hardening` (archived wish). Genie was supposed to follow but never did. The asymmetry is the root cause of every "the bridge is gone again" incident.
|
|
42
|
+
|
|
43
|
+
Same session also revealed: **multiple pgserve instances running in parallel** (3 distinct postgres-server.js processes, each on a different port). Every service that wants Postgres spins its own embedded pgserve. No single source of truth for connection strings; data dirs scattered across `~/.omni/data/pgserve/`, `~/.genie/data/pgserve/`, and `/dev/shm/pgserve-*`.
|
|
44
|
+
|
|
45
|
+
## Scope
|
|
46
|
+
|
|
47
|
+
### IN
|
|
48
|
+
|
|
49
|
+
1. **pgserve gets `install` + `serve` commands.** New subcommands in the pgserve CLI:
|
|
50
|
+
- `pgserve install` — idempotent pm2 registration with hardened defaults (mirror omni's `PM2_HARDENED_DEFAULTS`); creates `~/.pgserve/config.json` with canonical port + data dir.
|
|
51
|
+
- `pgserve serve` — long-lived process pm2 invokes (currently `bin/pgserve-wrapper.cjs daemon`, just renamed for clarity).
|
|
52
|
+
- `pgserve status` / `pgserve url` / `pgserve port` — discovery API for downstream installers.
|
|
53
|
+
- `pgserve uninstall` — `pm2 delete pgserve` + leave data dir intact.
|
|
54
|
+
|
|
55
|
+
2. **Hardened pm2 defaults shared.** Extract `PM2_HARDENED_DEFAULTS` and `buildPm2StartArgs` from `omni/packages/cli/src/pm2.ts` into a small shared shape every installer copies. Constants stay duplicated across repos (avoids a new shared package), but the values are pinned in this wish:
|
|
56
|
+
```
|
|
57
|
+
maxRestarts: 10
|
|
58
|
+
restartDelayMs: 5000
|
|
59
|
+
maxMemoryRestart: 2G (api/serve), 1G (nats)
|
|
60
|
+
killTimeoutMs: 20000
|
|
61
|
+
logDateFormat: YYYY-MM-DD HH:mm:ss.SSS
|
|
62
|
+
logs: ~/.<service>/logs/<name>-{out,error}.log
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
3. **`genie install` (NEW).** Mirror of `omni install`:
|
|
66
|
+
- Calls `pgserve install` first (no-op when already registered).
|
|
67
|
+
- Reads `pgserve url` to get the canonical connection string.
|
|
68
|
+
- Registers `genie-serve` under pm2 with hardened defaults.
|
|
69
|
+
- Writes `~/.genie/config.json` with `databaseUrl: <pgserve url>`.
|
|
70
|
+
- Idempotent; safe to re-run.
|
|
71
|
+
- Adds `--non-interactive` for CI/install.sh.
|
|
72
|
+
|
|
73
|
+
4. **`omni install` reconfigured.** Stops embedding pgserve inside `omni-api`'s lifecycle:
|
|
74
|
+
- Calls `pgserve install` first.
|
|
75
|
+
- Migration: pg_dump from current `~/.omni/data/pgserve/` → restore into canonical pgserve. Stop and pm2-delete the embedded pgserve.
|
|
76
|
+
- Update `omni-api`'s `DATABASE_URL` env to point at canonical pgserve.
|
|
77
|
+
- Existing `omni doctor` already audits this; extend it to check connection-string-points-at-canonical-pgserve.
|
|
78
|
+
|
|
79
|
+
5. **`install.sh` updates.** Both repos' bootstrap scripts route through the new pattern:
|
|
80
|
+
- `omni/install.sh`: install pgserve@latest globally → `pgserve install` → `omni install`.
|
|
81
|
+
- `genie/install.sh`: install pgserve@latest globally → `pgserve install` → `genie install`.
|
|
82
|
+
|
|
83
|
+
6. **Brain documentation.** Add to genie-configure's brain:
|
|
84
|
+
- `Configuration & Routing/canonical-pgserve-pm2.md` — architecture map: 4 pm2 services, pgserve as central PG, install ordering.
|
|
85
|
+
- `Runbooks/recover-pm2-stack.md` — how to diagnose / restart any of the 4 services; `pm2 resurrect` after reboot.
|
|
86
|
+
- `_decisions/2026-04-30-canonical-pgserve.md` — ADR documenting why one pgserve instead of N embedded.
|
|
87
|
+
|
|
88
|
+
### OUT
|
|
89
|
+
|
|
90
|
+
- **No replacement of pgserve with vanilla postgres.** pgserve stays; we only canonicalize how it's deployed.
|
|
91
|
+
- **No port migration tooling for third-party consumers.** If someone else's app talks to omni's old pgserve port directly, they update on their own.
|
|
92
|
+
- **No automatic uninstall of legacy embedded pgserve data dirs.** Migration copies forward; the old data stays on disk until operator removes it (avoids accidental data loss).
|
|
93
|
+
- **No multi-host pgserve cluster.** Single host only. Multi-host pgserve is a separate, much larger wish.
|
|
94
|
+
- **No systemd / launchd path.** pm2 is the single supervisor for this iteration. Aegis-runtime wish covers a future systemd-user variant.
|
|
95
|
+
|
|
96
|
+
## Decisions
|
|
97
|
+
|
|
98
|
+
| # | Decision | Rationale |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| 1 | pgserve owns the install + serve subcommands | Other services should NOT know how to register pgserve under pm2 — that's pgserve's responsibility. Same pattern as omni owning omni-api/nats. |
|
|
101
|
+
| 2 | Idempotent `*-install` everywhere | Every installer can be re-run without harm. Re-running `pgserve install` after it's already registered exits 0 with "already installed." Same for `omni install` and `genie install`. |
|
|
102
|
+
| 3 | Cross-repo install dependency: pgserve → omni & genie | omni and genie shell out to `pgserve install` first. They DON'T re-implement pgserve registration. Tighter coupling, but simpler than a shared package, and avoids "two installers disagree on hardening defaults." |
|
|
103
|
+
| 4 | `--interpreter none` for pm2 launches | Both genie and omni binaries use `#!/usr/bin/env bun` shebangs. `--interpreter bun` triggers pm2's ESM/require crash on top-level await. Shebang resolution side-steps the issue. **Empirically validated 2026-04-30** during the manual genie-serve pm2 registration. |
|
|
104
|
+
| 5 | `genie serve start --headless --no-tui --no-interactive` for pm2 | TUI requires a real terminal; pm2 child has no tty. Headless + no-tui matches omni-api's mode. **Empirically validated 2026-04-30.** |
|
|
105
|
+
| 6 | Migration via pg_dump + restore (not file-level copy) | Data file format is sensitive to PG version; pg_dump is portable. Even with same pgserve version, dump+restore is the safe path. |
|
|
106
|
+
| 7 | Single config file per service, no shared "canonical config" file | `pgserve install` writes `~/.pgserve/config.json`; consumers read it via `pgserve url`. We don't introduce a `~/.canonical/` directory or similar. The CLI is the contract. |
|
|
107
|
+
| 8 | pm2-logrotate stays as a module, not a pm2 service | It's a pm2 module by design; `omni install` already configures it. `pgserve install` reuses the same pm2-logrotate (no duplicate setup). |
|
|
108
|
+
|
|
109
|
+
## Success Criteria
|
|
110
|
+
|
|
111
|
+
- [ ] `pgserve install` registers `pgserve` as a pm2 service with hardened defaults; idempotent on second invocation.
|
|
112
|
+
- [ ] `pgserve url` returns a valid connection string that other tools can use without pgserve being CLI-imported.
|
|
113
|
+
- [ ] `omni install` on a clean machine results in: `pgserve` + `omni-api` + `omni-nats` all under pm2 with green status.
|
|
114
|
+
- [ ] `genie install` on a clean machine results in: `pgserve` + `genie-serve` all under pm2 with green status.
|
|
115
|
+
- [ ] On a machine where both omni and genie are installed, exactly **4 pm2 services** are present (pgserve, omni-api, omni-nats, genie-serve), pgserve is shared, and both `omni doctor` and `genie doctor` are green.
|
|
116
|
+
- [ ] On reboot, `pm2 resurrect` brings all 4 services back online with correct env.
|
|
117
|
+
- [ ] Existing omni installs migrate without data loss: pre-migration `omni events list` content matches post-migration content.
|
|
118
|
+
- [ ] `genie serve` running under pm2 survives shell closure (the bug that triggered this wish stays fixed forever).
|
|
119
|
+
- [ ] `omni doctor` and `genie doctor` both gain a check: "process is registered under pm2 with hardened defaults" (yes/no with one-line remediation if no).
|
|
120
|
+
- [ ] Brain entries (architecture map, runbook, ADR) merged in genie-configure.
|
|
121
|
+
|
|
122
|
+
## Execution Strategy
|
|
123
|
+
|
|
124
|
+
Wave-based; each wave can ship independently. Three repos, four PRs total.
|
|
125
|
+
|
|
126
|
+
### Wave 1 — `pgserve` foundation (BLOCKS waves 2 & 3)
|
|
127
|
+
|
|
128
|
+
**Goal:** pgserve owns its pm2 lifecycle.
|
|
129
|
+
|
|
130
|
+
- Group 1.1 — `pgserve install` + `pgserve serve` + `pgserve status` + `pgserve url` + `pgserve port`. Add `--non-interactive` for CI/install.sh. New file: `src/commands/install.ts` (mirror omni's structure).
|
|
131
|
+
- Group 1.2 — Tests: install idempotency, status reflects pm2 state, url/port match what install registered.
|
|
132
|
+
- Group 1.3 — README: document the 4 new subcommands.
|
|
133
|
+
|
|
134
|
+
**Validation:**
|
|
135
|
+
```bash
|
|
136
|
+
bunx pgserve install # green; pm2 list shows `pgserve`
|
|
137
|
+
bunx pgserve install # exits 0, "already installed"
|
|
138
|
+
bunx pgserve url # postgres://localhost:8432/postgres
|
|
139
|
+
bunx pgserve status --json # { name: "pgserve", status: "online", port: 8432, dataDir: "..." }
|
|
140
|
+
pm2 list | grep pgserve # online, max-restarts=10, etc.
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
**PR:** `namastexlabs/pgserve#???` — `feat(cli): pgserve install + pm2 supervision`.
|
|
144
|
+
|
|
145
|
+
### Wave 2 — `genie install` (depends on Wave 1)
|
|
146
|
+
|
|
147
|
+
**Goal:** Genie has parity with omni — `genie install` registers `genie-serve` under pm2 by calling `pgserve install` first.
|
|
148
|
+
|
|
149
|
+
- Group 2.1 — New `genie install` command in `src/genie-commands/install.ts`. Calls `pgserve install`, then `pm2 start` for genie-serve with the hardened args validated in this server's manual test (`--interpreter none` + `serve start --headless --no-tui --no-interactive`).
|
|
150
|
+
- Group 2.2 — Update `genie serve start` to detect when genie-serve is already pm2-supervised: print "Already managed by pm2; use `pm2 restart genie-serve`" and exit. Avoid the multi-instance lockfile dance.
|
|
151
|
+
- Group 2.3 — `genie doctor` adds `pm2-supervision` check.
|
|
152
|
+
- Group 2.4 — Tests for install command (with PM2 stubbed).
|
|
153
|
+
- Group 2.5 — `install.sh` updated to call `pgserve install` then `genie install`.
|
|
154
|
+
|
|
155
|
+
**Validation:**
|
|
156
|
+
```bash
|
|
157
|
+
genie install # green
|
|
158
|
+
pm2 list # includes pgserve + genie-serve
|
|
159
|
+
genie doctor # all green
|
|
160
|
+
genie serve stop && genie install # idempotent
|
|
161
|
+
# kill the shell that ran install — bridge stays alive (the original incident's reproduction)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
**PR:** `automagik-dev/genie#???` — `feat(cli): genie install + pm2 supervision`.
|
|
165
|
+
|
|
166
|
+
### Wave 3 — `omni install` reconfig (depends on Wave 1)
|
|
167
|
+
|
|
168
|
+
**Goal:** Omni's installer routes through canonical pgserve instead of the embedded one.
|
|
169
|
+
|
|
170
|
+
- Group 3.1 — `omni install` calls `pgserve install` before `omni-api` registration.
|
|
171
|
+
- Group 3.2 — Migration handler: detect existing `~/.omni/data/pgserve/` running under omni-api → pg_dump → restore into canonical pgserve → update omni-api `DATABASE_URL` env → delete embedded pgserve from pm2 → preserve old data dir on disk (operator can `rm -rf` later when satisfied).
|
|
172
|
+
- Group 3.3 — `omni doctor` adds `connection-string-canonical` check.
|
|
173
|
+
- Group 3.4 — Tests for migration path (start with embedded, run install, verify omni-api connects to canonical).
|
|
174
|
+
- Group 3.5 — `install.sh` updated to call `pgserve install` first.
|
|
175
|
+
|
|
176
|
+
**Validation:**
|
|
177
|
+
```bash
|
|
178
|
+
# Fresh machine
|
|
179
|
+
omni install
|
|
180
|
+
pm2 list # pgserve + omni-api + omni-nats
|
|
181
|
+
omni doctor # all green; connection-string-canonical=ok
|
|
182
|
+
|
|
183
|
+
# Existing machine (with embedded pgserve)
|
|
184
|
+
omni install # detects legacy, runs migration
|
|
185
|
+
omni events list --limit 100 # data preserved post-migration
|
|
186
|
+
pm2 list # pgserve + omni-api + omni-nats (no embedded pgserve)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
**PR:** `automagik-dev/omni#???` — `feat(install): canonical pgserve + migration from embedded`.
|
|
190
|
+
|
|
191
|
+
### Wave 4 — Brain ingestion (depends on Waves 1–3 merging)
|
|
192
|
+
|
|
193
|
+
**Goal:** Document the canonical layout so future agents inheriting any of these servers know the pattern by reading a single file.
|
|
194
|
+
|
|
195
|
+
- Group 4.1 — `brain/Configuration & Routing/canonical-pgserve-pm2.md`: architecture map; 4-service ascii diagram; pgserve discovery via `pgserve url`; install ordering.
|
|
196
|
+
- Group 4.2 — `brain/Runbooks/recover-pm2-stack.md`: diagnose/restart any of the 4 services; `pm2 resurrect` after reboot; rollback to embedded pgserve (if migration goes wrong).
|
|
197
|
+
- Group 4.3 — `brain/_decisions/2026-04-30-canonical-pgserve.md`: ADR; alternatives considered (vanilla postgres, systemd-user, embedded-everywhere); consequences.
|
|
198
|
+
|
|
199
|
+
**PR:** `namastexlabs/genie-configure#???` — `chore(brain): canonical pgserve + pm2 supervision`.
|
|
200
|
+
|
|
201
|
+
## Dependencies
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
Wave 1 (pgserve) ──┬──→ Wave 2 (genie)
|
|
205
|
+
├──→ Wave 3 (omni)
|
|
206
|
+
└──→ Wave 4 (brain — also depends on Wave 2 & 3)
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Cross-wish: closes the operator-lockout footgun the canonical-genie-omni-wiring + omni-host-fingerprint-trust wishes paved over with workarounds. Doesn't conflict with `aegis-runtime` (separate daemon, separate supervisor).
|
|
210
|
+
|
|
211
|
+
## QA Criteria
|
|
212
|
+
|
|
213
|
+
- [ ] On a fresh Ubuntu 24 box: `curl … omni/install.sh | bash` results in 3 pm2 services (pgserve + omni-api + omni-nats), green doctor.
|
|
214
|
+
- [ ] On the same box: `curl … genie/install.sh | bash` adds genie-serve = 4 pm2 services. pgserve shared.
|
|
215
|
+
- [ ] Reboot the box: `pm2 resurrect` brings all 4 back; both doctors green; bridge subscribes to NATS without manual intervention.
|
|
216
|
+
- [ ] Kill any one of the 4 services with SIGKILL: pm2 restarts it within 5 s; doctor goes red briefly then green.
|
|
217
|
+
- [ ] On a machine with the OLD embedded pgserve setup: `omni install` (post-Wave-3) migrates without data loss.
|
|
218
|
+
- [ ] `pgserve install` followed by `pgserve install --rotate-port 8433` correctly re-registers pgserve on the new port and updates omni-api/genie-serve env (or refuses cleanly if they're using the old port).
|
|
219
|
+
- [ ] `omni-host-fingerprint-trust` pipeline (the wish that closed two days before this one) keeps working — instances flagged `requireGenieSignature: true` still get gated correctly post-migration.
|
|
220
|
+
|
|
221
|
+
## Assumptions / Risks
|
|
222
|
+
|
|
223
|
+
| # | Item | Risk | Mitigation |
|
|
224
|
+
|---|---|---|---|
|
|
225
|
+
| 1 | pgserve repo accepts the new install/serve subcommands | Low — author is in the same org | If rejected, fall back to having omni and genie register pgserve directly (loses the "owned by pgserve" property but still gets us to 4 services). |
|
|
226
|
+
| 2 | Migration from embedded pgserve preserves all data | Medium — pg_dump on a live system + connection-string switch is non-trivial | Stage in Wave 3 with `--dry-run` first; document rollback. Take filesystem snapshot before running on production. |
|
|
227
|
+
| 3 | pm2 ESM/await crash with bun on future bun versions | Low | `--interpreter none` is robust; documented Decision 4. |
|
|
228
|
+
| 4 | Operators who customized their existing pgserve port will be confused | Medium | `omni doctor` and `genie doctor` add explicit "this service points at non-canonical pgserve" check with override flag. |
|
|
229
|
+
| 5 | NATS port also needs canonicalization (similar split-brain risk) | Out of scope for this wish | Park as a follow-up wish if it becomes a problem. omni-nats is single-instance today via pm2 so no urgency. |
|
|
230
|
+
| 6 | genie-configure (this brain) is not in the cycle | None | Wave 4 lands the docs in this repo only; no source code changes here. |
|
|
231
|
+
|
|
232
|
+
## Files to Create / Modify
|
|
233
|
+
|
|
234
|
+
### `namastexlabs/pgserve` (Wave 1)
|
|
235
|
+
- `src/commands/install.ts` (new)
|
|
236
|
+
- `src/commands/serve.ts` (new — likely a thin wrapper around the existing wrapper)
|
|
237
|
+
- `src/commands/status.ts`, `src/commands/url.ts`, `src/commands/port.ts` (new)
|
|
238
|
+
- `src/lib/pm2-args.ts` (new — shared pm2 launch builder, mirror of omni's)
|
|
239
|
+
- `bin/pgserve-wrapper.cjs` (modify — add subcommand routing)
|
|
240
|
+
- `__tests__/install.test.ts`, `__tests__/url.test.ts` (new)
|
|
241
|
+
- `README.md` (modify)
|
|
242
|
+
|
|
243
|
+
### `automagik-dev/genie` (Wave 2)
|
|
244
|
+
- `src/genie-commands/install.ts` (new)
|
|
245
|
+
- `src/genie-commands/doctor.ts` (modify — add pm2-supervision check)
|
|
246
|
+
- `src/term-commands/serve.ts` (modify — detect pm2 supervision, defer)
|
|
247
|
+
- `install.sh` (modify — route through `pgserve install` + `genie install`)
|
|
248
|
+
- `src/lib/pm2-args.ts` (new — copy from this wish's spec)
|
|
249
|
+
- Tests for install + doctor changes.
|
|
250
|
+
|
|
251
|
+
### `automagik-dev/omni` (Wave 3)
|
|
252
|
+
- `packages/cli/src/commands/install.ts` (modify — call `pgserve install` first; remove embedded pgserve registration)
|
|
253
|
+
- `packages/cli/src/lib/migrate-from-embedded-pgserve.ts` (new)
|
|
254
|
+
- `packages/cli/src/commands/doctor.ts` (modify — add canonical-connection-string check)
|
|
255
|
+
- `install.sh` (modify — `pgserve install` step)
|
|
256
|
+
- Tests for migration path.
|
|
257
|
+
|
|
258
|
+
### `namastexlabs/genie-configure` (Wave 4)
|
|
259
|
+
- `brain/Configuration & Routing/canonical-pgserve-pm2.md` (new)
|
|
260
|
+
- `brain/Runbooks/recover-pm2-stack.md` (new)
|
|
261
|
+
- `brain/_decisions/2026-04-30-canonical-pgserve.md` (new)
|
|
262
|
+
|
|
263
|
+
## Validated Beachhead (already shipped manually)
|
|
264
|
+
|
|
265
|
+
The genie-serve part is **already running under pm2** on this server as of 2026-04-30 16:08 UTC. Manual command used:
|
|
266
|
+
|
|
267
|
+
```bash
|
|
268
|
+
pm2 start /home/genie/.bun/bin/genie \
|
|
269
|
+
--name genie-serve \
|
|
270
|
+
--interpreter none \
|
|
271
|
+
--max-restarts 10 \
|
|
272
|
+
--restart-delay 5000 \
|
|
273
|
+
--max-memory-restart 2G \
|
|
274
|
+
--kill-timeout 20000 \
|
|
275
|
+
--log-date-format 'YYYY-MM-DD HH:mm:ss.SSS' \
|
|
276
|
+
--output ~/.genie/logs/genie-serve-out.log \
|
|
277
|
+
--error ~/.genie/logs/genie-serve-error.log \
|
|
278
|
+
-- serve start --headless --no-tui --no-interactive
|
|
279
|
+
|
|
280
|
+
pm2 save
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Wave 2 codifies this exact invocation as `genie install`. The args are pinned in Decisions 4 & 5.
|
|
284
|
+
|
|
285
|
+
## See also
|
|
286
|
+
|
|
287
|
+
- `omni-lifecycle-hardening` (archived) — established the omni-api pm2 hardening pattern this wish extends.
|
|
288
|
+
- `aegis-runtime` (draft) — different daemon, different supervisor (launchd/systemd-user), no conflict.
|
|
289
|
+
- `invincible-genie` (draft) — orthogonal: that wish is about `genie serve` self-healing; this wish is about `genie serve` being supervised in the first place. Both can ship independently.
|
|
290
|
+
- `pgserve-proxy-resilience` — sets up pgserve to exit cleanly when its child dies (so a supervisor can restart it). This wish is the supervisor side of that contract.
|
package/README.md
CHANGED
|
@@ -200,9 +200,45 @@ psql -h "${XDG_RUNTIME_DIR:-/tmp}/pgserve" -d myapp
|
|
|
200
200
|
psql "postgresql:///myapp?host=${XDG_RUNTIME_DIR:-/tmp}/pgserve"
|
|
201
201
|
```
|
|
202
202
|
|
|
203
|
-
### Supervised by PM2
|
|
203
|
+
### Supervised by PM2 — `pgserve install` (recommended)
|
|
204
204
|
|
|
205
|
-
`
|
|
205
|
+
`pgserve install` registers pgserve as a hardened pm2 process in one
|
|
206
|
+
command. Idempotent: re-running it is a no-op when already installed.
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pgserve install # one-shot register + start under pm2
|
|
210
|
+
pgserve install --port 8442 # custom port
|
|
211
|
+
pgserve install --data /data/pg # custom data dir
|
|
212
|
+
|
|
213
|
+
pgserve url # postgres://localhost:8432/postgres
|
|
214
|
+
pgserve port # 8432
|
|
215
|
+
pgserve status # pm2 + on-disk config snapshot
|
|
216
|
+
pgserve uninstall # remove from pm2; keep data dir
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Hardened defaults** (tuned for production-grade Postgres workloads,
|
|
220
|
+
not toy-machine values):
|
|
221
|
+
|
|
222
|
+
| Flag | Default | Why |
|
|
223
|
+
|------|---------|-----|
|
|
224
|
+
| `--max-memory-restart` | `4G` | Postgres realistic working set: shared_buffers + autovacuum + connection backends. 1G OOM-kills under modest load. Override with `PGSERVE_MAX_MEMORY=8G pgserve install`. |
|
|
225
|
+
| `--max-restarts` | `50` | Tolerates extended outages (NATS reconnect storms, host pressure). Combined with `--min-uptime`, only RAPID failures count. |
|
|
226
|
+
| `--min-uptime` | `10000` ms | Restart counts against the cap only when the process crashed within 10s of starting. Healthy long-uptime crashes don't burn the budget. |
|
|
227
|
+
| `--restart-delay` | `4000` ms | Initial gap between restarts. |
|
|
228
|
+
| `--exp-backoff-restart-delay` | `100` → ~60000 ms | Exponential spread on repeated failures so we don't hammer pm2 + the host on persistent issues. |
|
|
229
|
+
| `--kill-timeout` | `60000` ms | Postgres needs time to flush WAL on graceful shutdown; 60s headroom. |
|
|
230
|
+
| `--log-date-format` | `YYYY-MM-DD HH:mm:ss.SSS` | Operator-friendly timestamps in pm2 logs. |
|
|
231
|
+
| `--output` / `--error` | `~/.pgserve/logs/pgserve-{out,error}.log` | Rotates via pm2-logrotate (install separately). |
|
|
232
|
+
|
|
233
|
+
Config: `~/.pgserve/config.json` (override the directory with
|
|
234
|
+
`PGSERVE_CONFIG_DIR`). Memory ceiling: env-tunable via
|
|
235
|
+
`PGSERVE_MAX_MEMORY` at install time.
|
|
236
|
+
|
|
237
|
+
Downstream services that need a Postgres connection can shell out to
|
|
238
|
+
`pgserve install` (no-op if already running) and read the canonical URL
|
|
239
|
+
from `pgserve url` instead of spinning up their own embedded pgserve.
|
|
240
|
+
|
|
241
|
+
#### Manual ecosystem.config.cjs (legacy)
|
|
206
242
|
|
|
207
243
|
```javascript
|
|
208
244
|
module.exports = {
|
package/bin/pgserve-wrapper.cjs
CHANGED
|
@@ -14,6 +14,37 @@ const { spawn, execSync } = require('child_process');
|
|
|
14
14
|
const path = require('path');
|
|
15
15
|
const fs = require('fs');
|
|
16
16
|
|
|
17
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
18
|
+
// canonical-pgserve-pm2-supervision wish (PR #55, issue #56)
|
|
19
|
+
//
|
|
20
|
+
// `pgserve install / uninstall / status / url / port` are pure node + pm2
|
|
21
|
+
// wrappers — they don't need bun at all. Route them BEFORE the bun
|
|
22
|
+
// resolution + health probe so install works on a machine where bun
|
|
23
|
+
// hasn't self-healed yet (the chicken-and-egg case the probe was designed
|
|
24
|
+
// to detect — operators should be able to set up a fresh server even when
|
|
25
|
+
// the bun-postinstall failed).
|
|
26
|
+
//
|
|
27
|
+
// `pgserve serve` is an alias for the existing `pgserve daemon` (the
|
|
28
|
+
// long-lived process pm2 invokes); we rewrite argv so postgres-server.js
|
|
29
|
+
// sees the original `daemon` token.
|
|
30
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
31
|
+
const __subcommand = process.argv[2];
|
|
32
|
+
const __installSubcommands = new Set(['install', 'uninstall', 'status', 'url', 'port']);
|
|
33
|
+
if (__subcommand && __installSubcommands.has(__subcommand)) {
|
|
34
|
+
const cli = require(path.join(__dirname, '..', 'src', 'cli-install.cjs'));
|
|
35
|
+
process.exit(
|
|
36
|
+
cli.dispatch(__subcommand, process.argv.slice(3), {
|
|
37
|
+
scriptPath: path.join(__dirname, 'postgres-server.js'),
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
if (__subcommand === 'serve') {
|
|
42
|
+
// Alias `serve` → `daemon` so the wish's canonical command name maps
|
|
43
|
+
// cleanly to the existing long-lived process. Replacing argv preserves
|
|
44
|
+
// any flags the operator (or pm2) passed after `serve`.
|
|
45
|
+
process.argv[2] = 'daemon';
|
|
46
|
+
}
|
|
47
|
+
|
|
17
48
|
// Detect platform
|
|
18
49
|
const isWindows = process.platform === 'win32';
|
|
19
50
|
const bunBin = isWindows ? 'bun.exe' : 'bun';
|
|
@@ -53,6 +84,8 @@ if (!bunPath) {
|
|
|
53
84
|
process.exit(1);
|
|
54
85
|
}
|
|
55
86
|
|
|
87
|
+
const scriptPath = path.join(__dirname, 'postgres-server.js');
|
|
88
|
+
|
|
56
89
|
// Pre-flight health check: verify bun can actually execute.
|
|
57
90
|
//
|
|
58
91
|
// When pgserve is installed via `bun install` (as a global or transitive dep),
|
|
@@ -67,8 +100,6 @@ if (!bunPath) {
|
|
|
67
100
|
// If self-heal also fails, surface the real error instead of hanging later.
|
|
68
101
|
ensureBunHealthy(bunPath);
|
|
69
102
|
|
|
70
|
-
const scriptPath = path.join(__dirname, 'postgres-server.js');
|
|
71
|
-
|
|
72
103
|
/**
|
|
73
104
|
* Verify the selected bun binary can execute. If it fails with the known
|
|
74
105
|
* "postinstall script was not run" signature, attempt a one-shot repair via
|
package/bin/postgres-server.js
CHANGED
|
@@ -310,7 +310,14 @@ pgserve - Embedded PostgreSQL Server
|
|
|
310
310
|
True concurrent connections, zero config, auto-provision databases.
|
|
311
311
|
|
|
312
312
|
USAGE:
|
|
313
|
-
pgserve [options]
|
|
313
|
+
pgserve [options] # foreground server
|
|
314
|
+
pgserve install [--port N] # register under pm2 (idempotent)
|
|
315
|
+
pgserve serve # alias for "pgserve daemon"
|
|
316
|
+
pgserve status [--json] # report pm2 + config state
|
|
317
|
+
pgserve url # print canonical postgres:// URL
|
|
318
|
+
pgserve port # print canonical port
|
|
319
|
+
pgserve uninstall # remove from pm2 (keep data)
|
|
320
|
+
pgserve daemon [stop] # singleton daemon (Unix socket)
|
|
314
321
|
|
|
315
322
|
OPTIONS:
|
|
316
323
|
--port <number> PostgreSQL port (default: 8432)
|
package/package.json
CHANGED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pgserve install / uninstall / status / url / port subcommands.
|
|
3
|
+
*
|
|
4
|
+
* Wave 1 of the canonical-pgserve-pm2-supervision wish (PR #55, issue #56).
|
|
5
|
+
*
|
|
6
|
+
* These subcommands let pgserve own its pm2 lifecycle. Other services that
|
|
7
|
+
* need a Postgres connection (omni, genie, future) shell out to:
|
|
8
|
+
*
|
|
9
|
+
* pgserve install # idempotent, registers under pm2
|
|
10
|
+
* pgserve url # postgres://localhost:8432/postgres
|
|
11
|
+
*
|
|
12
|
+
* instead of spinning up their own embedded pgserve. End-state: a single
|
|
13
|
+
* shared pgserve under pm2 with hardened defaults, consumed by everyone.
|
|
14
|
+
*
|
|
15
|
+
* This module intentionally lives outside `bin/postgres-server.js` because
|
|
16
|
+
* none of these subcommands need bun (or a running PG backend) — they are
|
|
17
|
+
* filesystem + pm2 wrappers. Keeping them here means `pgserve install`
|
|
18
|
+
* works even when bun isn't healthy yet (the wrapper's bun-probe would
|
|
19
|
+
* otherwise block the install path).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { spawnSync, execFileSync } = require('node:child_process');
|
|
25
|
+
const fs = require('node:fs');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const path = require('node:path');
|
|
28
|
+
|
|
29
|
+
const PM2_PROCESS_NAME = 'pgserve';
|
|
30
|
+
const DEFAULT_PORT = 8432;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hardening defaults — tuned for production-grade elasticity, NOT
|
|
34
|
+
* the toy-machine values an initial draft of the wish carried.
|
|
35
|
+
*
|
|
36
|
+
* Earlier draft pinned `maxMemory: 1G` and `maxRestarts: 10`. The
|
|
37
|
+
* operator who reviewed PR #57 caught both as dangerously small for
|
|
38
|
+
* Postgres realistically:
|
|
39
|
+
* - 1G OOM-kills pgserve under modest load (shared_buffers + autovacuum
|
|
40
|
+
* workers + connection backends easily exceed 1G with a working set
|
|
41
|
+
* of any size).
|
|
42
|
+
* - 10 restart cap burns through during transient flakes (NATS reconnect
|
|
43
|
+
* loop, parent-process restart, host pressure spikes) before pm2
|
|
44
|
+
* gives up, leaving the operator with a stopped service in the
|
|
45
|
+
* morning.
|
|
46
|
+
*
|
|
47
|
+
* Revised defaults:
|
|
48
|
+
* - 4G memory ceiling — covers realistic load while still bounded so
|
|
49
|
+
* a runaway query can't eat the host.
|
|
50
|
+
* - 50 max restarts BUT only counted when min_uptime < 10s ("rapid"
|
|
51
|
+
* failures). Healthy long-uptime crashes don't count against the cap.
|
|
52
|
+
* - Exponential backoff on repeated failures (100ms → 60s) so we don't
|
|
53
|
+
* hammer on persistent issues.
|
|
54
|
+
* - 60s graceful shutdown window — Postgres needs time to flush WAL.
|
|
55
|
+
*
|
|
56
|
+
* Override at install time via env:
|
|
57
|
+
* PGSERVE_MAX_MEMORY=8G pgserve install
|
|
58
|
+
*
|
|
59
|
+
* These mirror the values omni and genie will use for their own pm2
|
|
60
|
+
* services. The constants are duplicated across repos (avoids a new
|
|
61
|
+
* shared package) but the values are pinned in the wish.
|
|
62
|
+
*/
|
|
63
|
+
const HARDENED_DEFAULTS = {
|
|
64
|
+
maxRestarts: 50,
|
|
65
|
+
minUptimeMs: 10_000,
|
|
66
|
+
restartDelayMs: 4000,
|
|
67
|
+
expBackoffRestartDelayMs: 100,
|
|
68
|
+
// pm2 caps `--exp-backoff-restart-delay` ramp at the current backoff
|
|
69
|
+
// doubling — practical max ~60s. Documented for operator clarity.
|
|
70
|
+
expBackoffMaxMs: 60_000,
|
|
71
|
+
maxMemory: process.env.PGSERVE_MAX_MEMORY || '4G',
|
|
72
|
+
killTimeoutMs: 60_000,
|
|
73
|
+
logDateFormat: 'YYYY-MM-DD HH:mm:ss.SSS',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
function getConfigDir() {
|
|
77
|
+
return process.env.PGSERVE_CONFIG_DIR || path.join(os.homedir(), '.pgserve');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getConfigPath() {
|
|
81
|
+
return path.join(getConfigDir(), 'config.json');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getLogsDir() {
|
|
85
|
+
return path.join(getConfigDir(), 'logs');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getDataDir() {
|
|
89
|
+
return path.join(getConfigDir(), 'data');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function readConfig() {
|
|
93
|
+
const p = getConfigPath();
|
|
94
|
+
if (!fs.existsSync(p)) return null;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function writeConfig(config) {
|
|
103
|
+
const dir = getConfigDir();
|
|
104
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
105
|
+
const tmp = `${getConfigPath()}.tmp.${process.pid}`;
|
|
106
|
+
fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o644 });
|
|
107
|
+
fs.renameSync(tmp, getConfigPath());
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Run `pm2 jlist` and return the entry for our process, or null when not
|
|
112
|
+
* registered. Returns null on any failure (pm2 missing, JSON parse error,
|
|
113
|
+
* etc.) — callers should treat that as "not installed" rather than crash.
|
|
114
|
+
*/
|
|
115
|
+
function pm2GetProcess(name) {
|
|
116
|
+
try {
|
|
117
|
+
const out = execFileSync('pm2', ['jlist'], {
|
|
118
|
+
encoding: 'utf8',
|
|
119
|
+
timeout: 5000,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
121
|
+
});
|
|
122
|
+
const list = JSON.parse(out);
|
|
123
|
+
return list.find((p) => p && p.name === name) || null;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pm2IsAvailable() {
|
|
130
|
+
try {
|
|
131
|
+
execFileSync('pm2', ['--version'], { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
132
|
+
return true;
|
|
133
|
+
} catch {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildPm2StartArgs({ scriptPath, port, dataDir }) {
|
|
139
|
+
const logs = {
|
|
140
|
+
out: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-out.log`),
|
|
141
|
+
error: path.join(getLogsDir(), `${PM2_PROCESS_NAME}-error.log`),
|
|
142
|
+
};
|
|
143
|
+
return [
|
|
144
|
+
'start',
|
|
145
|
+
scriptPath,
|
|
146
|
+
'--name',
|
|
147
|
+
PM2_PROCESS_NAME,
|
|
148
|
+
'--interpreter',
|
|
149
|
+
'none',
|
|
150
|
+
'--max-restarts',
|
|
151
|
+
String(HARDENED_DEFAULTS.maxRestarts),
|
|
152
|
+
// `--min-uptime` makes `--max-restarts` count only RAPID failures
|
|
153
|
+
// (process crashed within N ms of starting). Healthy long-uptime
|
|
154
|
+
// crashes don't burn the budget.
|
|
155
|
+
'--min-uptime',
|
|
156
|
+
String(HARDENED_DEFAULTS.minUptimeMs),
|
|
157
|
+
'--restart-delay',
|
|
158
|
+
String(HARDENED_DEFAULTS.restartDelayMs),
|
|
159
|
+
// Exponential backoff between successive failures: starts at 100ms,
|
|
160
|
+
// doubles each crash, ramps to ~60s. Avoids hammering pm2 + the host
|
|
161
|
+
// when the underlying issue is persistent.
|
|
162
|
+
'--exp-backoff-restart-delay',
|
|
163
|
+
String(HARDENED_DEFAULTS.expBackoffRestartDelayMs),
|
|
164
|
+
'--max-memory-restart',
|
|
165
|
+
HARDENED_DEFAULTS.maxMemory,
|
|
166
|
+
'--kill-timeout',
|
|
167
|
+
String(HARDENED_DEFAULTS.killTimeoutMs),
|
|
168
|
+
'--log-date-format',
|
|
169
|
+
HARDENED_DEFAULTS.logDateFormat,
|
|
170
|
+
'--output',
|
|
171
|
+
logs.out,
|
|
172
|
+
'--error',
|
|
173
|
+
logs.error,
|
|
174
|
+
'--',
|
|
175
|
+
'daemon',
|
|
176
|
+
'--port',
|
|
177
|
+
String(port),
|
|
178
|
+
'--data',
|
|
179
|
+
dataDir,
|
|
180
|
+
'--log',
|
|
181
|
+
'warn',
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function ensureLogsDir() {
|
|
186
|
+
const dir = getLogsDir();
|
|
187
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o755 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function fail(message) {
|
|
191
|
+
process.stderr.write(`pgserve: ${message}\n`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function note(message) {
|
|
196
|
+
process.stderr.write(`pgserve: ${message}\n`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function ok(message) {
|
|
200
|
+
process.stdout.write(`pgserve: ${message}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* `pgserve install [--port N] [--data PATH]`
|
|
205
|
+
*
|
|
206
|
+
* Idempotent. When the process is already registered, prints a reuse line
|
|
207
|
+
* and exits 0 without touching anything. Otherwise: writes `~/.pgserve/
|
|
208
|
+
* config.json` (creating the dir if needed), then registers the process
|
|
209
|
+
* under pm2 with the hardened defaults.
|
|
210
|
+
*
|
|
211
|
+
* `scriptPath` is the path to `bin/postgres-server.js` resolved by the
|
|
212
|
+
* wrapper before this module is required (avoids re-resolving here).
|
|
213
|
+
*/
|
|
214
|
+
function cmdInstall(args, ctx) {
|
|
215
|
+
if (!pm2IsAvailable()) {
|
|
216
|
+
fail('pm2 not found in PATH. Install with: bun add -g pm2 (or npm i -g pm2)');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const port = parsePort(args) ?? readConfig()?.port ?? DEFAULT_PORT;
|
|
220
|
+
const dataDir = parseDataDir(args) ?? readConfig()?.dataDir ?? getDataDir();
|
|
221
|
+
|
|
222
|
+
// Idempotent: already-registered = no-op success.
|
|
223
|
+
const existing = pm2GetProcess(PM2_PROCESS_NAME);
|
|
224
|
+
if (existing) {
|
|
225
|
+
ok(`already installed (pm2 process "${PM2_PROCESS_NAME}", status=${existing.pm2_env?.status ?? 'unknown'})`);
|
|
226
|
+
// Refresh config in case install was re-run with new flags — but
|
|
227
|
+
// don't tear down the live process. Operators wanting a port change
|
|
228
|
+
// should `uninstall` then `install`.
|
|
229
|
+
writeConfig({ port, dataDir, registeredAt: readConfig()?.registeredAt ?? new Date().toISOString() });
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
ensureLogsDir();
|
|
234
|
+
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true, mode: 0o700 });
|
|
235
|
+
|
|
236
|
+
const pm2Args = buildPm2StartArgs({ scriptPath: ctx.scriptPath, port, dataDir });
|
|
237
|
+
const result = spawnSync('pm2', pm2Args, { stdio: 'inherit' });
|
|
238
|
+
if (result.status !== 0) {
|
|
239
|
+
fail(`pm2 start failed (exit ${result.status}). Logs: ${getLogsDir()}/${PM2_PROCESS_NAME}-error.log`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
writeConfig({ port, dataDir, registeredAt: new Date().toISOString() });
|
|
243
|
+
ok(`installed: pm2 process "${PM2_PROCESS_NAME}" on port ${port} (data: ${dataDir})`);
|
|
244
|
+
ok(`url: postgres://localhost:${port}/postgres`);
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* `pgserve uninstall`
|
|
250
|
+
*
|
|
251
|
+
* Removes pgserve from pm2. Leaves the data directory and config file
|
|
252
|
+
* intact — operator can `rm -rf ~/.pgserve` after they're satisfied no
|
|
253
|
+
* downstream service still depends on the data.
|
|
254
|
+
*/
|
|
255
|
+
function cmdUninstall() {
|
|
256
|
+
const existing = pm2GetProcess(PM2_PROCESS_NAME);
|
|
257
|
+
if (!existing) {
|
|
258
|
+
ok(`not registered under pm2 (nothing to uninstall)`);
|
|
259
|
+
return 0;
|
|
260
|
+
}
|
|
261
|
+
const result = spawnSync('pm2', ['delete', PM2_PROCESS_NAME], { stdio: 'inherit' });
|
|
262
|
+
if (result.status !== 0) {
|
|
263
|
+
fail(`pm2 delete failed (exit ${result.status})`);
|
|
264
|
+
}
|
|
265
|
+
ok(`uninstalled (pm2 process removed; data dir preserved at ${getDataDir()})`);
|
|
266
|
+
return 0;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* `pgserve status [--json]`
|
|
271
|
+
*
|
|
272
|
+
* Reports both pm2 state and on-disk config. Exits 0 with status info
|
|
273
|
+
* regardless of running/stopped — operators script around the JSON output.
|
|
274
|
+
* Non-zero only when the config is missing entirely (i.e. pgserve was
|
|
275
|
+
* never installed).
|
|
276
|
+
*/
|
|
277
|
+
function cmdStatus(args) {
|
|
278
|
+
const json = args.includes('--json');
|
|
279
|
+
const config = readConfig();
|
|
280
|
+
if (!config) {
|
|
281
|
+
if (json) {
|
|
282
|
+
process.stdout.write(`${JSON.stringify({ installed: false })}\n`);
|
|
283
|
+
} else {
|
|
284
|
+
ok('not installed (run: pgserve install)');
|
|
285
|
+
}
|
|
286
|
+
return 1;
|
|
287
|
+
}
|
|
288
|
+
const proc = pm2GetProcess(PM2_PROCESS_NAME);
|
|
289
|
+
const status = proc?.pm2_env?.status ?? 'stopped';
|
|
290
|
+
const pid = proc?.pid ?? null;
|
|
291
|
+
const uptimeMs = proc?.pm2_env?.pm_uptime ? Date.now() - proc.pm2_env.pm_uptime : null;
|
|
292
|
+
const restarts = proc?.pm2_env?.restart_time ?? 0;
|
|
293
|
+
|
|
294
|
+
const payload = {
|
|
295
|
+
installed: true,
|
|
296
|
+
name: PM2_PROCESS_NAME,
|
|
297
|
+
status,
|
|
298
|
+
pid,
|
|
299
|
+
port: config.port,
|
|
300
|
+
dataDir: config.dataDir,
|
|
301
|
+
logsDir: getLogsDir(),
|
|
302
|
+
url: `postgres://localhost:${config.port}/postgres`,
|
|
303
|
+
uptimeMs,
|
|
304
|
+
restarts,
|
|
305
|
+
registeredAt: config.registeredAt,
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (json) {
|
|
309
|
+
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
310
|
+
return 0;
|
|
311
|
+
}
|
|
312
|
+
process.stdout.write(`name ${payload.name}\n`);
|
|
313
|
+
process.stdout.write(`status ${payload.status}${payload.pid ? ` (pid ${payload.pid})` : ''}\n`);
|
|
314
|
+
process.stdout.write(`port ${payload.port}\n`);
|
|
315
|
+
process.stdout.write(`url ${payload.url}\n`);
|
|
316
|
+
process.stdout.write(`dataDir ${payload.dataDir}\n`);
|
|
317
|
+
process.stdout.write(`logsDir ${payload.logsDir}\n`);
|
|
318
|
+
if (payload.uptimeMs != null) {
|
|
319
|
+
const sec = Math.floor(payload.uptimeMs / 1000);
|
|
320
|
+
process.stdout.write(`uptime ${sec}s\n`);
|
|
321
|
+
}
|
|
322
|
+
process.stdout.write(`restarts ${payload.restarts}\n`);
|
|
323
|
+
process.stdout.write(`registered ${payload.registeredAt}\n`);
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* `pgserve url`
|
|
329
|
+
*
|
|
330
|
+
* Discovery API. Prints the canonical connection string. Downstream
|
|
331
|
+
* installers (genie install, omni install) call this to learn where to
|
|
332
|
+
* connect, instead of hardcoding a port.
|
|
333
|
+
*/
|
|
334
|
+
function cmdUrl() {
|
|
335
|
+
const config = readConfig();
|
|
336
|
+
if (!config) {
|
|
337
|
+
fail('not installed (run: pgserve install)');
|
|
338
|
+
}
|
|
339
|
+
process.stdout.write(`postgres://localhost:${config.port}/postgres\n`);
|
|
340
|
+
return 0;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `pgserve port` — print the canonical port. */
|
|
344
|
+
function cmdPort() {
|
|
345
|
+
const config = readConfig();
|
|
346
|
+
if (!config) {
|
|
347
|
+
fail('not installed (run: pgserve install)');
|
|
348
|
+
}
|
|
349
|
+
process.stdout.write(`${config.port}\n`);
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function parsePort(args) {
|
|
354
|
+
const i = args.indexOf('--port');
|
|
355
|
+
if (i < 0) return null;
|
|
356
|
+
const v = args[i + 1];
|
|
357
|
+
if (!v) fail('--port requires a value');
|
|
358
|
+
const n = Number.parseInt(v, 10);
|
|
359
|
+
if (!Number.isInteger(n) || n < 1 || n > 65535) fail(`invalid --port "${v}"`);
|
|
360
|
+
return n;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseDataDir(args) {
|
|
364
|
+
const i = args.indexOf('--data');
|
|
365
|
+
if (i < 0) return null;
|
|
366
|
+
const v = args[i + 1];
|
|
367
|
+
if (!v) fail('--data requires a value');
|
|
368
|
+
return path.resolve(v);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Entry point invoked by the wrapper. Returns the exit code. Throws on
|
|
373
|
+
* unknown subcommand so the wrapper's normal flow can take over (the
|
|
374
|
+
* router treats any non-recognized subcommand as "pass through to the
|
|
375
|
+
* postgres-server.js dispatcher").
|
|
376
|
+
*/
|
|
377
|
+
function dispatch(subcommand, args, ctx) {
|
|
378
|
+
switch (subcommand) {
|
|
379
|
+
case 'install':
|
|
380
|
+
return cmdInstall(args, ctx);
|
|
381
|
+
case 'uninstall':
|
|
382
|
+
return cmdUninstall();
|
|
383
|
+
case 'status':
|
|
384
|
+
return cmdStatus(args);
|
|
385
|
+
case 'url':
|
|
386
|
+
return cmdUrl();
|
|
387
|
+
case 'port':
|
|
388
|
+
return cmdPort();
|
|
389
|
+
default:
|
|
390
|
+
throw new Error(`pgserve: dispatch called with unknown subcommand "${subcommand}"`);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
module.exports = {
|
|
395
|
+
// Public API for the wrapper.
|
|
396
|
+
dispatch,
|
|
397
|
+
// Test surface.
|
|
398
|
+
_internals: {
|
|
399
|
+
HARDENED_DEFAULTS,
|
|
400
|
+
PM2_PROCESS_NAME,
|
|
401
|
+
DEFAULT_PORT,
|
|
402
|
+
getConfigDir,
|
|
403
|
+
getConfigPath,
|
|
404
|
+
getLogsDir,
|
|
405
|
+
getDataDir,
|
|
406
|
+
readConfig,
|
|
407
|
+
writeConfig,
|
|
408
|
+
buildPm2StartArgs,
|
|
409
|
+
parsePort,
|
|
410
|
+
parseDataDir,
|
|
411
|
+
},
|
|
412
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for src/cli-install.cjs — pgserve install/uninstall/status/url/port.
|
|
3
|
+
*
|
|
4
|
+
* Wave 1 of the canonical-pgserve-pm2-supervision wish (PR #55, issue #56).
|
|
5
|
+
*
|
|
6
|
+
* Strategy: drive the pure paths (config read/write, arg parsing, pm2-args
|
|
7
|
+
* builder) directly. The pm2-spawning paths (install / uninstall) are
|
|
8
|
+
* exercised by spawning the real pgserve binary against a temp HOME so
|
|
9
|
+
* `pm2` is invoked but with no real daemon side-effect when pm2 is
|
|
10
|
+
* either absent OR the test stubs its calls via PATH.
|
|
11
|
+
*
|
|
12
|
+
* No test in this file actually starts pgserve. We only verify the CLI
|
|
13
|
+
* surface — the daemon lifecycle is covered by daemon-control.test.js.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { test, expect, beforeEach, afterEach, describe } from 'bun:test';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
|
|
22
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
23
|
+
const BIN = path.join(REPO_ROOT, 'bin', 'pgserve-wrapper.cjs');
|
|
24
|
+
|
|
25
|
+
let tmpHome;
|
|
26
|
+
let stubBin;
|
|
27
|
+
let originalConfigDir;
|
|
28
|
+
let originalPath;
|
|
29
|
+
|
|
30
|
+
function makeStubPm2(mode = 'success') {
|
|
31
|
+
// mode: 'success' | 'failure' | 'missing'
|
|
32
|
+
// Writes a stub `pm2` script into a tempdir we prepend to PATH.
|
|
33
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-stub-pm2-'));
|
|
34
|
+
if (mode === 'missing') {
|
|
35
|
+
// Don't create a stub; PATH still has our dir but no pm2 binary.
|
|
36
|
+
return { dir, calls: [] };
|
|
37
|
+
}
|
|
38
|
+
const callLog = path.join(dir, 'calls.log');
|
|
39
|
+
const exitCode = mode === 'failure' ? 1 : 0;
|
|
40
|
+
// jlist returns either an empty list (so install proceeds) or a fake
|
|
41
|
+
// process record (so subsequent install calls hit the idempotent
|
|
42
|
+
// path). We toggle via a sentinel file the test owns.
|
|
43
|
+
const script = `#!/usr/bin/env node
|
|
44
|
+
const fs = require('node:fs');
|
|
45
|
+
const args = process.argv.slice(2);
|
|
46
|
+
fs.appendFileSync(${JSON.stringify(callLog)}, JSON.stringify(args) + '\\n');
|
|
47
|
+
if (args[0] === '--version') { process.stdout.write('5.0.0-stub\\n'); process.exit(0); }
|
|
48
|
+
if (args[0] === 'jlist') {
|
|
49
|
+
const sentinel = ${JSON.stringify(path.join(dir, 'registered'))};
|
|
50
|
+
if (fs.existsSync(sentinel)) {
|
|
51
|
+
process.stdout.write(JSON.stringify([{
|
|
52
|
+
name: 'pgserve',
|
|
53
|
+
pid: 12345,
|
|
54
|
+
pm2_env: { status: 'online', pm_uptime: Date.now() - 1000, restart_time: 0 }
|
|
55
|
+
}]) + '\\n');
|
|
56
|
+
} else {
|
|
57
|
+
process.stdout.write('[]\\n');
|
|
58
|
+
}
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
if (args[0] === 'start') {
|
|
62
|
+
fs.writeFileSync(${JSON.stringify(path.join(dir, 'registered'))}, '');
|
|
63
|
+
process.exit(${exitCode});
|
|
64
|
+
}
|
|
65
|
+
if (args[0] === 'delete') {
|
|
66
|
+
try { fs.unlinkSync(${JSON.stringify(path.join(dir, 'registered'))}); } catch {}
|
|
67
|
+
process.exit(${exitCode});
|
|
68
|
+
}
|
|
69
|
+
process.exit(0);
|
|
70
|
+
`;
|
|
71
|
+
const pm2Path = path.join(dir, 'pm2');
|
|
72
|
+
fs.writeFileSync(pm2Path, script, { mode: 0o755 });
|
|
73
|
+
return { dir, calls: callLog };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readCallLog(callsPath) {
|
|
77
|
+
if (!fs.existsSync(callsPath)) return [];
|
|
78
|
+
return fs.readFileSync(callsPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runCli(args, env = {}) {
|
|
82
|
+
return spawnSync('node', [BIN, ...args], {
|
|
83
|
+
encoding: 'utf8',
|
|
84
|
+
env: {
|
|
85
|
+
...process.env,
|
|
86
|
+
PGSERVE_CONFIG_DIR: tmpHome,
|
|
87
|
+
PATH: `${stubBin.dir}:${process.env.PATH}`,
|
|
88
|
+
...env,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-cfg-'));
|
|
95
|
+
stubBin = makeStubPm2('success');
|
|
96
|
+
originalConfigDir = process.env.PGSERVE_CONFIG_DIR;
|
|
97
|
+
originalPath = process.env.PATH;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
102
|
+
if (stubBin?.dir) fs.rmSync(stubBin.dir, { recursive: true, force: true });
|
|
103
|
+
if (originalConfigDir === undefined) delete process.env.PGSERVE_CONFIG_DIR;
|
|
104
|
+
else process.env.PGSERVE_CONFIG_DIR = originalConfigDir;
|
|
105
|
+
process.env.PATH = originalPath;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('pgserve install', () => {
|
|
109
|
+
test('first install registers under pm2 and writes config', () => {
|
|
110
|
+
const result = runCli(['install']);
|
|
111
|
+
expect(result.status).toBe(0);
|
|
112
|
+
expect(result.stdout).toContain('installed');
|
|
113
|
+
expect(result.stdout).toContain('postgres://localhost:8432');
|
|
114
|
+
|
|
115
|
+
const config = JSON.parse(fs.readFileSync(path.join(tmpHome, 'config.json'), 'utf8'));
|
|
116
|
+
expect(config.port).toBe(8432);
|
|
117
|
+
expect(config.dataDir).toBe(path.join(tmpHome, 'data'));
|
|
118
|
+
expect(config.registeredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
119
|
+
|
|
120
|
+
const calls = readCallLog(stubBin.calls);
|
|
121
|
+
const startCall = calls.find((c) => c[0] === 'start');
|
|
122
|
+
expect(startCall).toBeDefined();
|
|
123
|
+
expect(startCall).toContain('--name');
|
|
124
|
+
expect(startCall).toContain('pgserve');
|
|
125
|
+
expect(startCall).toContain('--max-restarts');
|
|
126
|
+
expect(startCall).toContain('50');
|
|
127
|
+
expect(startCall).toContain('--min-uptime');
|
|
128
|
+
expect(startCall).toContain('--exp-backoff-restart-delay');
|
|
129
|
+
expect(startCall).toContain('--max-memory-restart');
|
|
130
|
+
expect(startCall).toContain('4G');
|
|
131
|
+
expect(startCall).toContain('--kill-timeout');
|
|
132
|
+
expect(startCall).toContain('60000');
|
|
133
|
+
expect(startCall).toContain('--interpreter');
|
|
134
|
+
expect(startCall).toContain('none');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('second install is idempotent (no second pm2 start)', () => {
|
|
138
|
+
runCli(['install']);
|
|
139
|
+
const calls1 = readCallLog(stubBin.calls);
|
|
140
|
+
const startCount1 = calls1.filter((c) => c[0] === 'start').length;
|
|
141
|
+
expect(startCount1).toBe(1);
|
|
142
|
+
|
|
143
|
+
const result2 = runCli(['install']);
|
|
144
|
+
expect(result2.status).toBe(0);
|
|
145
|
+
expect(result2.stdout).toContain('already installed');
|
|
146
|
+
|
|
147
|
+
const calls2 = readCallLog(stubBin.calls);
|
|
148
|
+
const startCount2 = calls2.filter((c) => c[0] === 'start').length;
|
|
149
|
+
expect(startCount2).toBe(1); // no second start
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('--port overrides default', () => {
|
|
153
|
+
const result = runCli(['install', '--port', '8442']);
|
|
154
|
+
expect(result.status).toBe(0);
|
|
155
|
+
const config = JSON.parse(fs.readFileSync(path.join(tmpHome, 'config.json'), 'utf8'));
|
|
156
|
+
expect(config.port).toBe(8442);
|
|
157
|
+
expect(result.stdout).toContain('postgres://localhost:8442');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('rejects malformed --port', () => {
|
|
161
|
+
const result = runCli(['install', '--port', 'not-a-number']);
|
|
162
|
+
expect(result.status).not.toBe(0);
|
|
163
|
+
expect(result.stderr).toContain('invalid --port');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('PGSERVE_MAX_MEMORY env overrides the default memory ceiling', () => {
|
|
167
|
+
const result = runCli(['install'], { PGSERVE_MAX_MEMORY: '8G' });
|
|
168
|
+
expect(result.status).toBe(0);
|
|
169
|
+
const calls = readCallLog(stubBin.calls);
|
|
170
|
+
const startCall = calls.find((c) => c[0] === 'start');
|
|
171
|
+
// The env value flows through to pm2's --max-memory-restart flag so
|
|
172
|
+
// operators on big-iron hosts can tune up without a recompile.
|
|
173
|
+
expect(startCall).toContain('8G');
|
|
174
|
+
expect(startCall).not.toContain('4G');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('fails clearly when pm2 is missing', () => {
|
|
178
|
+
// Build a sanitized PATH that has node (so spawnSync can resolve the
|
|
179
|
+
// interpreter) but explicitly NO directory containing pm2. Skipping
|
|
180
|
+
// /usr/bin etc. would make the test brittle on different hosts.
|
|
181
|
+
fs.rmSync(stubBin.dir, { recursive: true, force: true });
|
|
182
|
+
stubBin = makeStubPm2('missing');
|
|
183
|
+
const nodeDir = path.dirname(process.execPath);
|
|
184
|
+
const sanitizedPath = (process.env.PATH || '')
|
|
185
|
+
.split(':')
|
|
186
|
+
.filter((p) => {
|
|
187
|
+
try {
|
|
188
|
+
return !fs.existsSync(path.join(p, 'pm2'));
|
|
189
|
+
} catch {
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
.concat([nodeDir, stubBin.dir])
|
|
194
|
+
.join(':');
|
|
195
|
+
const result = spawnSync('node', [BIN, 'install'], {
|
|
196
|
+
encoding: 'utf8',
|
|
197
|
+
env: { ...process.env, PGSERVE_CONFIG_DIR: tmpHome, PATH: sanitizedPath },
|
|
198
|
+
});
|
|
199
|
+
expect(result.status).not.toBe(0);
|
|
200
|
+
expect(result.stderr).toContain('pm2 not found');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('pgserve url / port', () => {
|
|
205
|
+
test('url after install prints canonical connection string', () => {
|
|
206
|
+
runCli(['install']);
|
|
207
|
+
const result = runCli(['url']);
|
|
208
|
+
expect(result.status).toBe(0);
|
|
209
|
+
expect(result.stdout.trim()).toBe('postgres://localhost:8432/postgres');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('port after install prints the registered port', () => {
|
|
213
|
+
runCli(['install', '--port', '8442']);
|
|
214
|
+
const result = runCli(['port']);
|
|
215
|
+
expect(result.status).toBe(0);
|
|
216
|
+
expect(result.stdout.trim()).toBe('8442');
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('url before install fails with helpful message', () => {
|
|
220
|
+
const result = runCli(['url']);
|
|
221
|
+
expect(result.status).not.toBe(0);
|
|
222
|
+
expect(result.stderr).toContain('not installed');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('pgserve status', () => {
|
|
227
|
+
test('status before install reports installed=false (exit 1)', () => {
|
|
228
|
+
const result = runCli(['status', '--json']);
|
|
229
|
+
expect(result.status).toBe(1);
|
|
230
|
+
const out = JSON.parse(result.stdout);
|
|
231
|
+
expect(out.installed).toBe(false);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('status after install reports running with port from config', () => {
|
|
235
|
+
runCli(['install', '--port', '8482']);
|
|
236
|
+
const result = runCli(['status', '--json']);
|
|
237
|
+
expect(result.status).toBe(0);
|
|
238
|
+
const out = JSON.parse(result.stdout);
|
|
239
|
+
expect(out.installed).toBe(true);
|
|
240
|
+
expect(out.name).toBe('pgserve');
|
|
241
|
+
expect(out.status).toBe('online');
|
|
242
|
+
expect(out.port).toBe(8482);
|
|
243
|
+
expect(out.url).toBe('postgres://localhost:8482/postgres');
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('status human-readable output includes port + url', () => {
|
|
247
|
+
runCli(['install']);
|
|
248
|
+
const result = runCli(['status']);
|
|
249
|
+
expect(result.status).toBe(0);
|
|
250
|
+
expect(result.stdout).toContain('port');
|
|
251
|
+
expect(result.stdout).toContain('8432');
|
|
252
|
+
expect(result.stdout).toContain('postgres://localhost:8432/postgres');
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
describe('pgserve uninstall', () => {
|
|
257
|
+
test('uninstall removes pm2 process but preserves config', () => {
|
|
258
|
+
runCli(['install']);
|
|
259
|
+
expect(fs.existsSync(path.join(tmpHome, 'config.json'))).toBe(true);
|
|
260
|
+
|
|
261
|
+
const result = runCli(['uninstall']);
|
|
262
|
+
expect(result.status).toBe(0);
|
|
263
|
+
expect(result.stdout).toContain('uninstalled');
|
|
264
|
+
|
|
265
|
+
const calls = readCallLog(stubBin.calls);
|
|
266
|
+
expect(calls.find((c) => c[0] === 'delete' && c[1] === 'pgserve')).toBeDefined();
|
|
267
|
+
|
|
268
|
+
// Config preserved so a re-install reuses port/dataDir.
|
|
269
|
+
expect(fs.existsSync(path.join(tmpHome, 'config.json'))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('uninstall when not installed is a no-op success', () => {
|
|
273
|
+
const result = runCli(['uninstall']);
|
|
274
|
+
expect(result.status).toBe(0);
|
|
275
|
+
expect(result.stdout).toContain('not registered');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe('serve alias', () => {
|
|
280
|
+
test('pgserve serve --help re-routes to daemon (which postgres-server.js handles)', () => {
|
|
281
|
+
// We can't fully exercise `serve` without starting a real daemon.
|
|
282
|
+
// Instead, verify the wrapper's argv-rewrite happens by passing
|
|
283
|
+
// `serve --bogus-flag` and asserting the wrapper proceeded past the
|
|
284
|
+
// install short-circuit (i.e. stderr mentions bun, not "pgserve: ...").
|
|
285
|
+
// Note: bun probe might fail in tests; we don't assert exit code.
|
|
286
|
+
const result = spawnSync('node', [BIN, 'serve', '--bogus-flag'], {
|
|
287
|
+
encoding: 'utf8',
|
|
288
|
+
env: {
|
|
289
|
+
...process.env,
|
|
290
|
+
PGSERVE_CONFIG_DIR: tmpHome,
|
|
291
|
+
PATH: `${stubBin.dir}:${process.env.PATH}`,
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
// Must NOT have hit the install dispatcher (would print
|
|
295
|
+
// "pgserve: not installed" or similar). Because serve passes through
|
|
296
|
+
// to the bun + postgres-server.js path, we expect EITHER a bun error
|
|
297
|
+
// OR a daemon-mode error — never an install-module error.
|
|
298
|
+
expect(result.stderr).not.toContain('pgserve: not installed');
|
|
299
|
+
expect(result.stderr).not.toContain('pm2 not found');
|
|
300
|
+
});
|
|
301
|
+
});
|