@dashclaw/cli 0.7.1 → 0.7.3

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/lib/up/db.js CHANGED
@@ -1,318 +1,355 @@
1
- // cli/lib/up/db.js
2
- //
3
- // Database mode selection and provisioning for `dashclaw up`.
4
- //
5
- // Three modes:
6
- // docker — starts/reuses a `dashclaw-pg` Docker container (host port 5433,
7
- // or the next free port when 5433 is taken by something else)
8
- // embedded — downloads and runs embedded Postgres via embedded-postgres (~40 MB, first run)
9
- // url — prompts for a postgresql:// connection string from the user
10
- //
11
- // Port policy: prefer DEFAULT_DB_PORT (5433, mirroring the repo's
12
- // docker-compose.yml). Dev machines often already have a Postgres on 5433 —
13
- // hard-failing there with a raw `docker run` error was a real first-run
14
- // killer, so provisioning scans forward to the first free port instead and
15
- // says so. Continuity beats novelty: an existing container keeps whatever
16
- // host port it was created with, and a saved databaseUrl's port is preferred
17
- // on resume so a working install never silently moves.
18
-
19
- import { join } from 'node:path';
20
- import { existsSync, rmSync } from 'node:fs';
21
- import { execFileSync, spawnSync } from 'node:child_process';
22
-
23
- // Keep this in sync with the "embedded-postgres" version in cli/package.json.
24
- const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
25
-
26
- export const DEFAULT_DB_PORT = 5433;
27
- // Scan window when the preferred port is taken: preferred+1 .. preferred+10.
28
- const PORT_SCAN_SPAN = 10;
29
-
30
- export function localDbUrlFor(port) {
31
- return `postgresql://dashclaw:dashclaw@localhost:${port}/dashclaw`;
32
- }
33
-
34
- // Back-compat export (the default-port URL).
35
- export const LOCAL_DB_URL = localDbUrlFor(DEFAULT_DB_PORT);
36
-
37
- const CONTAINER = 'dashclaw-pg';
38
-
39
- /** Returns true when the `docker` binary is available and responsive. */
40
- export function dockerAvailableSync() {
41
- return spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0;
42
- }
43
-
44
- /**
45
- * Determines which database mode to use.
46
- *
47
- * Priority:
48
- * 1. Explicit --db flag
49
- * 2. Non-interactive (--yes): docker if available, otherwise embedded
50
- * 3. Interactive prompt
51
- *
52
- * @param {object} opts
53
- * @param {string|null} opts.flagDb - explicit --db value or null
54
- * @param {boolean} opts.dockerAvailable
55
- * @param {boolean} [opts.yes] - non-interactive mode
56
- * @param {Function} [opts.promptFn] - async (message) => string
57
- * @returns {Promise<'docker'|'embedded'|'url'>}
58
- */
59
- export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn }) {
60
- if (flagDb) return flagDb;
61
- if (yes) return dockerAvailable ? 'docker' : 'embedded';
62
-
63
- const lines = [
64
- 'Database — pick one:',
65
- dockerAvailable
66
- ? ' 1. Docker Postgres (Docker detected) [default]'
67
- : ' 1. Docker Postgres (Docker NOT detected)',
68
- ` 2. Embedded Postgres (no Docker needed, ~40 MB download)${dockerAvailable ? '' : ' [default]'}`,
69
- ' 3. I have a postgresql:// URL',
70
- ];
71
- const def = dockerAvailable ? '1' : '2';
72
- const answer = (await promptFn(`${lines.join('\n')}\nChoice [${def}]: `)).trim() || def;
73
- return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
74
- }
75
-
76
- /**
77
- * True when nothing on this machine is serving :port.
78
- *
79
- * Two probes, both required — a single one lies on Windows, where binding
80
- * 127.0.0.1:port SUCCEEDS while another process holds 0.0.0.0:port (observed
81
- * live: the probe said 5433 was free while a Docker proxy held it):
82
- * 1. connect() to 127.0.0.1:port — an accepted connection means a live
83
- * listener regardless of which address it bound.
84
- * 2. listen() on the wildcard address — fails when the port is held at
85
- * 0.0.0.0/[::] even if nothing accepts on loopback.
86
- */
87
- export async function isPortFree(port) {
88
- const net = await import('node:net');
89
- const accepts = await new Promise((resolve) => {
90
- const s = net.connect({ port, host: '127.0.0.1' });
91
- const done = (v) => { s.destroy(); resolve(v); };
92
- s.once('connect', () => done(true));
93
- s.once('error', () => done(false));
94
- s.setTimeout(1000, () => done(false));
95
- });
96
- if (accepts) return false;
97
- return new Promise((resolve) => {
98
- const srv = net.createServer();
99
- srv.once('error', () => resolve(false));
100
- srv.once('listening', () => srv.close(() => resolve(true)));
101
- srv.listen(port); // wildcard bind on purpose — see above
102
- });
103
- }
104
-
105
- /**
106
- * Picks the host port for a locally provisioned Postgres: the preferred port
107
- * if free, otherwise the first free port in the scan window (logged, so the
108
- * deviation is never silent). Throws when the whole window is occupied.
109
- *
110
- * @param {object} [opts]
111
- * @param {number} [opts.preferred=DEFAULT_DB_PORT]
112
- * @param {Function} [opts.isFree=isPortFree] injectable for tests
113
- * @param {object} [opts.logger]
114
- * @returns {Promise<number>}
115
- */
116
- export async function pickDbPort({ preferred = DEFAULT_DB_PORT, isFree = isPortFree, logger = console } = {}) {
117
- if (await isFree(preferred)) return preferred;
118
- for (let p = preferred + 1; p <= preferred + PORT_SCAN_SPAN; p++) {
119
- if (await isFree(p)) {
120
- logger.error(`[warn] Port ${preferred} is already in use — using free port ${p} for Postgres instead.`);
121
- return p;
122
- }
123
- }
124
- throw new Error(
125
- `No free port for Postgres: ${preferred}-${preferred + PORT_SCAN_SPAN} are all in use. `
126
- + 'Free one up, or re-run with --db url and your own postgresql:// connection string.',
127
- );
128
- }
129
-
130
- /**
131
- * Returns the docker run command that starts a local Postgres container.
132
- * Mirrors the repo's docker-compose.yml: postgres:16-alpine, dashclaw/dashclaw/
133
- * dashclaw credentials, named volume dashclaw_pgdata. Host port is a parameter
134
- * (default 5433) so provisioning can route around an occupied port.
135
- */
136
- export function dockerCommandFor(port = DEFAULT_DB_PORT) {
137
- return {
138
- cmd: 'docker',
139
- args: [
140
- 'run', '-d', '--name', CONTAINER,
141
- '-e', 'POSTGRES_USER=dashclaw',
142
- '-e', 'POSTGRES_PASSWORD=dashclaw',
143
- '-e', 'POSTGRES_DB=dashclaw',
144
- '-p', `${port}:5432`,
145
- '-v', 'dashclaw_pgdata:/var/lib/postgresql/data',
146
- 'postgres:16-alpine',
147
- ],
148
- };
149
- }
150
-
151
- /** Runs a docker CLI call, capturing stderr so failures are explainable. */
152
- function dockerExec(args) {
153
- try {
154
- return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
155
- } catch (e) {
156
- const stderr = (e.stderr || '').toString().trim();
157
- throw new Error(`docker ${args[0]} failed${stderr ? `: ${stderr}` : `: ${e.message}`}`);
158
- }
159
- }
160
-
161
- /** The real docker effects injectable in provisionDatabase for tests. */
162
- export const realDockerOps = {
163
- /** Container id (any state), or '' when it does not exist. */
164
- containerId: () => (spawnSync(
165
- 'docker', ['ps', '-aq', '--filter', `name=^${CONTAINER}$`],
166
- { encoding: 'utf8' },
167
- ).stdout || '').trim(),
168
- /** Container id when RUNNING, or ''. */
169
- runningId: () => (spawnSync(
170
- 'docker', ['ps', '-q', '--filter', `name=^${CONTAINER}$`],
171
- { encoding: 'utf8' },
172
- ).stdout || '').trim(),
173
- /**
174
- * Host port the existing container maps to 5432, or null. Reads the
175
- * configured binding via `docker inspect` because `docker port` reports
176
- * nothing for containers that are created/stopped rather than running.
177
- */
178
- mappedPort: () => {
179
- const out = spawnSync('docker', [
180
- 'inspect', CONTAINER,
181
- '--format', '{{(index (index .HostConfig.PortBindings "5432/tcp") 0).HostPort}}',
182
- ], { encoding: 'utf8' }).stdout || '';
183
- const m = /^(\d+)$/.exec(out.trim());
184
- return m ? Number(m[1]) : null;
185
- },
186
- start: () => dockerExec(['start', CONTAINER]),
187
- remove: () => dockerExec(['rm', CONTAINER]),
188
- run: (port) => {
189
- try {
190
- dockerExec(dockerCommandFor(port).args);
191
- } catch (e) {
192
- // A failed `docker run` can leave a half-created container behind,
193
- // which would poison every retry with "name already in use" — clean
194
- // it up best-effort before surfacing the real error.
195
- try { execFileSync('docker', ['rm', '-f', CONTAINER], { stdio: 'ignore' }); } catch { /* nothing to clean */ }
196
- throw e;
197
- }
198
- },
199
- };
200
-
201
- /**
202
- * Starts (or creates) the dashclaw-pg container and returns the host port it
203
- * serves on. Continuity first: a running container is used as-is on whatever
204
- * port it maps; a stopped container is restarted on its recorded port. Only
205
- * when that recorded port has been taken by some OTHER process is the
206
- * container recreated on a fresh free port — safe by construction, because
207
- * all data lives in the named volume `dashclaw_pgdata`, not the container.
208
- */
209
- async function dockerProvision({ preferredPort, ops, isFree, logger }) {
210
- if (ops.containerId()) {
211
- const mapped = ops.mappedPort();
212
- if (ops.runningId() && mapped) {
213
- logger.error(`[ok] Docker Postgres already running (container ${CONTAINER}, port ${mapped})`);
214
- return mapped;
215
- }
216
- if (mapped && await isFree(mapped)) {
217
- ops.start();
218
- logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${mapped})`);
219
- return mapped;
220
- }
221
- // The container's port is now held by something else (or the mapping is
222
- // unreadable) recreate it on a free port. Data survives in the volume.
223
- logger.error(
224
- `[warn] Container ${CONTAINER} maps port ${mapped ?? '?'} which is now in use by another process — `
225
- + 'recreating the container on a free port (data volume dashclaw_pgdata is preserved).',
226
- );
227
- ops.remove();
228
- }
229
- const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
230
- ops.run(port);
231
- logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${port})`);
232
- return port;
233
- }
234
-
235
- /**
236
- * Provisions the database according to `mode` and returns { databaseUrl, stop }.
237
- *
238
- * @param {object} opts
239
- * @param {'docker'|'embedded'|'url'} opts.mode
240
- * @param {string} opts.baseDir - base dir for embedded data (e.g. ~/.dashclaw)
241
- * @param {Function} [opts.promptFn] - async (message) => string (required for mode=url)
242
- * @param {string} [opts.savedDatabaseUrl] - databaseUrl from a prior run (port continuity)
243
- * @param {object} [opts.dockerOps] - injectable docker effects (default realDockerOps)
244
- * @param {Function} [opts.isFree] - injectable port probe (default isPortFree)
245
- * @param {Function} [opts.waitForDbPort] - injectable readiness wait (default waitForPort)
246
- * @param {object} [opts.logger] - object with .error(); defaults to console
247
- * @returns {Promise<{ databaseUrl: string, stop: () => Promise<void> }>}
248
- */
249
- export async function provisionDatabase({
250
- mode, baseDir, promptFn, savedDatabaseUrl,
251
- dockerOps = realDockerOps, isFree = isPortFree, waitForDbPort = waitForPort, logger = console,
252
- }) {
253
- let preferredPort = DEFAULT_DB_PORT;
254
- if (savedDatabaseUrl) {
255
- try { preferredPort = Number(new URL(savedDatabaseUrl).port) || DEFAULT_DB_PORT; } catch { /* keep default */ }
256
- }
257
-
258
- if (mode === 'url') {
259
- const url = (await promptFn('postgresql:// connection string: ')).trim();
260
- if (!url.startsWith('postgresql://')) throw new Error('That is not a postgresql:// URL.');
261
- return { databaseUrl: url, stop: async () => {} };
262
- }
263
-
264
- if (mode === 'docker') {
265
- const port = await dockerProvision({ preferredPort, ops: dockerOps, isFree, logger });
266
- await waitForDbPort(port, 30_000);
267
- return { databaseUrl: localDbUrlFor(port), stop: async () => {} };
268
- }
269
-
270
- // mode === 'embedded'
271
- const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
272
- const { default: EmbeddedPostgres } = await import('embedded-postgres');
273
- const pgDir = join(baseDir, 'pg');
274
- // Record whether the data dir existed BEFORE this run.
275
- // Only clean up on failure when WE created it (a pre-existing dir means
276
- // a working prior install whose data we must not delete).
277
- const dirPreExisted = existsSync(pgDir);
278
- // Derive credentials from LOCAL_DB_URL so the local dev creds live in
279
- // exactly one place rather than being duplicated as bare literals here.
280
- const localDb = new URL(LOCAL_DB_URL);
281
- const pg = new EmbeddedPostgres({
282
- databaseDir: pgDir,
283
- user: localDb.username,
284
- password: localDb.password,
285
- port,
286
- persistent: true,
287
- });
288
- try {
289
- await pg.initialise();
290
- await pg.start();
291
- } catch (e) {
292
- if (!dirPreExisted) {
293
- rmSync(pgDir, { recursive: true, force: true });
294
- }
295
- throw new Error(
296
- `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${e.message}. Retry with --db docker or --db url.`,
297
- );
298
- }
299
- try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
300
- logger.error(`[ok] Embedded Postgres running (port ${port}, data in ${pgDir})`);
301
- return { databaseUrl: localDbUrlFor(port), stop: () => pg.stop() };
302
- }
303
-
304
- /** Polls :port until it accepts TCP connections, or throws on timeout. */
305
- async function waitForPort(port, timeoutMs) {
306
- const net = await import('node:net');
307
- const deadline = Date.now() + timeoutMs;
308
- while (Date.now() < deadline) {
309
- const ok = await new Promise((resolve) => {
310
- const s = net.connect(port, '127.0.0.1');
311
- s.once('connect', () => { s.destroy(); resolve(true); });
312
- s.once('error', () => resolve(false));
313
- });
314
- if (ok) return;
315
- await new Promise((r) => setTimeout(r, 500));
316
- }
317
- throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
318
- }
1
+ // cli/lib/up/db.js
2
+ //
3
+ // Database mode selection and provisioning for `dashclaw up`.
4
+ //
5
+ // Three modes:
6
+ // docker — starts/reuses a `dashclaw-pg` Docker container (host port 5433,
7
+ // or the next free port when 5433 is taken by something else)
8
+ // embedded — downloads and runs embedded Postgres via embedded-postgres (~40 MB, first run)
9
+ // url — prompts for a postgresql:// connection string from the user
10
+ //
11
+ // Port policy: prefer DEFAULT_DB_PORT (5433, mirroring the repo's
12
+ // docker-compose.yml). Dev machines often already have a Postgres on 5433 —
13
+ // hard-failing there with a raw `docker run` error was a real first-run
14
+ // killer, so provisioning scans forward to the first free port instead and
15
+ // says so. Continuity beats novelty: an existing container keeps whatever
16
+ // host port it was created with, and a saved databaseUrl's port is preferred
17
+ // on resume so a working install never silently moves.
18
+
19
+ import { join } from 'node:path';
20
+ import { existsSync, rmSync } from 'node:fs';
21
+ import { execFileSync, spawnSync } from 'node:child_process';
22
+
23
+ // Keep this in sync with the "embedded-postgres" version in cli/package.json.
24
+ const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
25
+
26
+ // Windows exit status 0xC0000135 (STATUS_DLL_NOT_FOUND). The embedded
27
+ // Postgres binaries link against the Microsoft Visual C++ runtime, which a
28
+ // fresh Windows install (and Windows Sandbox) does not ship — initdb dies at
29
+ // exec with this code and EMPTY stderr, which reads as pure noise to a
30
+ // first-run user.
31
+ const STATUS_DLL_NOT_FOUND = '3221225781';
32
+ const VC_REDIST_HINT =
33
+ 'Embedded Postgres needs the Microsoft Visual C++ runtime, which this Windows machine does not have. '
34
+ + 'Install it once from https://aka.ms/vs/17/release/vc_redist.x64.exe (or `winget install Microsoft.VCRedist.2015+.x64`), '
35
+ + 'then re-run `npx dashclaw up`. Alternatively retry with --db docker or --db url.';
36
+
37
+ /**
38
+ * True on Windows machines missing the VC++ runtime DLLs the embedded
39
+ * Postgres binaries need. Checked BEFORE the embedded attempt so the failure
40
+ * is actionable instead of a raw 0xC0000135 with empty stderr.
41
+ */
42
+ export function missingVcRuntime({ platform = process.platform, exists = existsSync } = {}) {
43
+ if (platform !== 'win32') return false;
44
+ const sys32 = join(process.env.SystemRoot || 'C:\\Windows', 'System32');
45
+ return !(exists(join(sys32, 'vcruntime140.dll')) && exists(join(sys32, 'msvcp140.dll')));
46
+ }
47
+
48
+ /**
49
+ * Renders an embedded-postgres failure as an actionable message. Maps the
50
+ * Windows DLL-not-found exit code to the VC++ runtime remediation even when
51
+ * the preflight passed (a different DLL may be the missing one).
52
+ */
53
+ export function embeddedFailureMessage(err) {
54
+ const base = `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${err.message}`;
55
+ if (String(err.message).includes(STATUS_DLL_NOT_FOUND)) {
56
+ return `${base} this exit code means a required system DLL is missing. ${VC_REDIST_HINT}`;
57
+ }
58
+ return `${base}. Retry with --db docker or --db url.`;
59
+ }
60
+
61
+ export const DEFAULT_DB_PORT = 5433;
62
+ // Scan window when the preferred port is taken: preferred+1 .. preferred+10.
63
+ const PORT_SCAN_SPAN = 10;
64
+
65
+ export function localDbUrlFor(port) {
66
+ return `postgresql://dashclaw:dashclaw@localhost:${port}/dashclaw`;
67
+ }
68
+
69
+ // Back-compat export (the default-port URL).
70
+ export const LOCAL_DB_URL = localDbUrlFor(DEFAULT_DB_PORT);
71
+
72
+ const CONTAINER = 'dashclaw-pg';
73
+
74
+ /** Returns true when the `docker` binary is available and responsive. */
75
+ export function dockerAvailableSync() {
76
+ return spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0;
77
+ }
78
+
79
+ /**
80
+ * Determines which database mode to use.
81
+ *
82
+ * Priority:
83
+ * 1. Explicit --db flag
84
+ * 2. Non-interactive (--yes): docker if available, otherwise embedded
85
+ * 3. Interactive prompt
86
+ *
87
+ * @param {object} opts
88
+ * @param {string|null} opts.flagDb - explicit --db value or null
89
+ * @param {boolean} opts.dockerAvailable
90
+ * @param {boolean} [opts.yes] - non-interactive mode
91
+ * @param {Function} [opts.promptFn] - async (message) => string
92
+ * @returns {Promise<'docker'|'embedded'|'url'>}
93
+ */
94
+ export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn }) {
95
+ if (flagDb) return flagDb;
96
+ if (yes) return dockerAvailable ? 'docker' : 'embedded';
97
+
98
+ const lines = [
99
+ 'Database pick one:',
100
+ dockerAvailable
101
+ ? ' 1. Docker Postgres (Docker detected) [default]'
102
+ : ' 1. Docker Postgres (Docker NOT detected)',
103
+ ` 2. Embedded Postgres (no Docker needed, ~40 MB download)${dockerAvailable ? '' : ' [default]'}`,
104
+ ' 3. I have a postgresql:// URL',
105
+ ];
106
+ const def = dockerAvailable ? '1' : '2';
107
+ const answer = (await promptFn(`${lines.join('\n')}\nChoice [${def}]: `)).trim() || def;
108
+ return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
109
+ }
110
+
111
+ /**
112
+ * True when nothing on this machine is serving :port.
113
+ *
114
+ * Two probes, both required — a single one lies on Windows, where binding
115
+ * 127.0.0.1:port SUCCEEDS while another process holds 0.0.0.0:port (observed
116
+ * live: the probe said 5433 was free while a Docker proxy held it):
117
+ * 1. connect() to 127.0.0.1:port — an accepted connection means a live
118
+ * listener regardless of which address it bound.
119
+ * 2. listen() on the wildcard address — fails when the port is held at
120
+ * 0.0.0.0/[::] even if nothing accepts on loopback.
121
+ */
122
+ export async function isPortFree(port) {
123
+ const net = await import('node:net');
124
+ const accepts = await new Promise((resolve) => {
125
+ const s = net.connect({ port, host: '127.0.0.1' });
126
+ const done = (v) => { s.destroy(); resolve(v); };
127
+ s.once('connect', () => done(true));
128
+ s.once('error', () => done(false));
129
+ s.setTimeout(1000, () => done(false));
130
+ });
131
+ if (accepts) return false;
132
+ return new Promise((resolve) => {
133
+ const srv = net.createServer();
134
+ srv.once('error', () => resolve(false));
135
+ srv.once('listening', () => srv.close(() => resolve(true)));
136
+ srv.listen(port); // wildcard bind on purpose — see above
137
+ });
138
+ }
139
+
140
+ /**
141
+ * Picks the host port for a locally provisioned Postgres: the preferred port
142
+ * if free, otherwise the first free port in the scan window (logged, so the
143
+ * deviation is never silent). Throws when the whole window is occupied.
144
+ *
145
+ * @param {object} [opts]
146
+ * @param {number} [opts.preferred=DEFAULT_DB_PORT]
147
+ * @param {Function} [opts.isFree=isPortFree] injectable for tests
148
+ * @param {object} [opts.logger]
149
+ * @returns {Promise<number>}
150
+ */
151
+ export async function pickDbPort({ preferred = DEFAULT_DB_PORT, isFree = isPortFree, logger = console } = {}) {
152
+ if (await isFree(preferred)) return preferred;
153
+ for (let p = preferred + 1; p <= preferred + PORT_SCAN_SPAN; p++) {
154
+ if (await isFree(p)) {
155
+ logger.error(`[warn] Port ${preferred} is already in use — using free port ${p} for Postgres instead.`);
156
+ return p;
157
+ }
158
+ }
159
+ throw new Error(
160
+ `No free port for Postgres: ${preferred}-${preferred + PORT_SCAN_SPAN} are all in use. `
161
+ + 'Free one up, or re-run with --db url and your own postgresql:// connection string.',
162
+ );
163
+ }
164
+
165
+ /**
166
+ * Returns the docker run command that starts a local Postgres container.
167
+ * Mirrors the repo's docker-compose.yml: postgres:16-alpine, dashclaw/dashclaw/
168
+ * dashclaw credentials, named volume dashclaw_pgdata. Host port is a parameter
169
+ * (default 5433) so provisioning can route around an occupied port.
170
+ */
171
+ export function dockerCommandFor(port = DEFAULT_DB_PORT) {
172
+ return {
173
+ cmd: 'docker',
174
+ args: [
175
+ 'run', '-d', '--name', CONTAINER,
176
+ '-e', 'POSTGRES_USER=dashclaw',
177
+ '-e', 'POSTGRES_PASSWORD=dashclaw',
178
+ '-e', 'POSTGRES_DB=dashclaw',
179
+ '-p', `${port}:5432`,
180
+ '-v', 'dashclaw_pgdata:/var/lib/postgresql/data',
181
+ 'postgres:16-alpine',
182
+ ],
183
+ };
184
+ }
185
+
186
+ /** Runs a docker CLI call, capturing stderr so failures are explainable. */
187
+ function dockerExec(args) {
188
+ try {
189
+ return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
190
+ } catch (e) {
191
+ const stderr = (e.stderr || '').toString().trim();
192
+ throw new Error(`docker ${args[0]} failed${stderr ? `: ${stderr}` : `: ${e.message}`}`);
193
+ }
194
+ }
195
+
196
+ /** The real docker effects — injectable in provisionDatabase for tests. */
197
+ export const realDockerOps = {
198
+ /** Container id (any state), or '' when it does not exist. */
199
+ containerId: () => (spawnSync(
200
+ 'docker', ['ps', '-aq', '--filter', `name=^${CONTAINER}$`],
201
+ { encoding: 'utf8' },
202
+ ).stdout || '').trim(),
203
+ /** Container id when RUNNING, or ''. */
204
+ runningId: () => (spawnSync(
205
+ 'docker', ['ps', '-q', '--filter', `name=^${CONTAINER}$`],
206
+ { encoding: 'utf8' },
207
+ ).stdout || '').trim(),
208
+ /**
209
+ * Host port the existing container maps to 5432, or null. Reads the
210
+ * configured binding via `docker inspect` because `docker port` reports
211
+ * nothing for containers that are created/stopped rather than running.
212
+ */
213
+ mappedPort: () => {
214
+ const out = spawnSync('docker', [
215
+ 'inspect', CONTAINER,
216
+ '--format', '{{(index (index .HostConfig.PortBindings "5432/tcp") 0).HostPort}}',
217
+ ], { encoding: 'utf8' }).stdout || '';
218
+ const m = /^(\d+)$/.exec(out.trim());
219
+ return m ? Number(m[1]) : null;
220
+ },
221
+ start: () => dockerExec(['start', CONTAINER]),
222
+ remove: () => dockerExec(['rm', CONTAINER]),
223
+ run: (port) => {
224
+ try {
225
+ dockerExec(dockerCommandFor(port).args);
226
+ } catch (e) {
227
+ // A failed `docker run` can leave a half-created container behind,
228
+ // which would poison every retry with "name already in use" — clean
229
+ // it up best-effort before surfacing the real error.
230
+ try { execFileSync('docker', ['rm', '-f', CONTAINER], { stdio: 'ignore' }); } catch { /* nothing to clean */ }
231
+ throw e;
232
+ }
233
+ },
234
+ };
235
+
236
+ /**
237
+ * Starts (or creates) the dashclaw-pg container and returns the host port it
238
+ * serves on. Continuity first: a running container is used as-is on whatever
239
+ * port it maps; a stopped container is restarted on its recorded port. Only
240
+ * when that recorded port has been taken by some OTHER process is the
241
+ * container recreated on a fresh free port safe by construction, because
242
+ * all data lives in the named volume `dashclaw_pgdata`, not the container.
243
+ */
244
+ async function dockerProvision({ preferredPort, ops, isFree, logger }) {
245
+ if (ops.containerId()) {
246
+ const mapped = ops.mappedPort();
247
+ if (ops.runningId() && mapped) {
248
+ logger.error(`[ok] Docker Postgres already running (container ${CONTAINER}, port ${mapped})`);
249
+ return mapped;
250
+ }
251
+ if (mapped && await isFree(mapped)) {
252
+ ops.start();
253
+ logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${mapped})`);
254
+ return mapped;
255
+ }
256
+ // The container's port is now held by something else (or the mapping is
257
+ // unreadable) — recreate it on a free port. Data survives in the volume.
258
+ logger.error(
259
+ `[warn] Container ${CONTAINER} maps port ${mapped ?? '?'} which is now in use by another process — `
260
+ + 'recreating the container on a free port (data volume dashclaw_pgdata is preserved).',
261
+ );
262
+ ops.remove();
263
+ }
264
+ const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
265
+ ops.run(port);
266
+ logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${port})`);
267
+ return port;
268
+ }
269
+
270
+ /**
271
+ * Provisions the database according to `mode` and returns { databaseUrl, stop }.
272
+ *
273
+ * @param {object} opts
274
+ * @param {'docker'|'embedded'|'url'} opts.mode
275
+ * @param {string} opts.baseDir - base dir for embedded data (e.g. ~/.dashclaw)
276
+ * @param {Function} [opts.promptFn] - async (message) => string (required for mode=url)
277
+ * @param {string} [opts.savedDatabaseUrl] - databaseUrl from a prior run (port continuity)
278
+ * @param {object} [opts.dockerOps] - injectable docker effects (default realDockerOps)
279
+ * @param {Function} [opts.isFree] - injectable port probe (default isPortFree)
280
+ * @param {Function} [opts.waitForDbPort] - injectable readiness wait (default waitForPort)
281
+ * @param {object} [opts.logger] - object with .error(); defaults to console
282
+ * @returns {Promise<{ databaseUrl: string, stop: () => Promise<void> }>}
283
+ */
284
+ export async function provisionDatabase({
285
+ mode, baseDir, promptFn, savedDatabaseUrl,
286
+ dockerOps = realDockerOps, isFree = isPortFree, waitForDbPort = waitForPort, logger = console,
287
+ checkVcRuntime = missingVcRuntime,
288
+ }) {
289
+ let preferredPort = DEFAULT_DB_PORT;
290
+ if (savedDatabaseUrl) {
291
+ try { preferredPort = Number(new URL(savedDatabaseUrl).port) || DEFAULT_DB_PORT; } catch { /* keep default */ }
292
+ }
293
+
294
+ if (mode === 'url') {
295
+ const url = (await promptFn('postgresql:// connection string: ')).trim();
296
+ if (!url.startsWith('postgresql://')) throw new Error('That is not a postgresql:// URL.');
297
+ return { databaseUrl: url, stop: async () => {} };
298
+ }
299
+
300
+ if (mode === 'docker') {
301
+ const port = await dockerProvision({ preferredPort, ops: dockerOps, isFree, logger });
302
+ await waitForDbPort(port, 30_000);
303
+ return { databaseUrl: localDbUrlFor(port), stop: async () => {} };
304
+ }
305
+
306
+ // mode === 'embedded'
307
+ if (checkVcRuntime()) {
308
+ throw new Error(VC_REDIST_HINT);
309
+ }
310
+ const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
311
+ const { default: EmbeddedPostgres } = await import('embedded-postgres');
312
+ const pgDir = join(baseDir, 'pg');
313
+ // Record whether the data dir existed BEFORE this run.
314
+ // Only clean up on failure when WE created it (a pre-existing dir means
315
+ // a working prior install whose data we must not delete).
316
+ const dirPreExisted = existsSync(pgDir);
317
+ // Derive credentials from LOCAL_DB_URL so the local dev creds live in
318
+ // exactly one place rather than being duplicated as bare literals here.
319
+ const localDb = new URL(LOCAL_DB_URL);
320
+ const pg = new EmbeddedPostgres({
321
+ databaseDir: pgDir,
322
+ user: localDb.username,
323
+ password: localDb.password,
324
+ port,
325
+ persistent: true,
326
+ });
327
+ try {
328
+ await pg.initialise();
329
+ await pg.start();
330
+ } catch (e) {
331
+ if (!dirPreExisted) {
332
+ rmSync(pgDir, { recursive: true, force: true });
333
+ }
334
+ throw new Error(embeddedFailureMessage(e));
335
+ }
336
+ try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
337
+ logger.error(`[ok] Embedded Postgres running (port ${port}, data in ${pgDir})`);
338
+ return { databaseUrl: localDbUrlFor(port), stop: () => pg.stop() };
339
+ }
340
+
341
+ /** Polls :port until it accepts TCP connections, or throws on timeout. */
342
+ async function waitForPort(port, timeoutMs) {
343
+ const net = await import('node:net');
344
+ const deadline = Date.now() + timeoutMs;
345
+ while (Date.now() < deadline) {
346
+ const ok = await new Promise((resolve) => {
347
+ const s = net.connect(port, '127.0.0.1');
348
+ s.once('connect', () => { s.destroy(); resolve(true); });
349
+ s.once('error', () => resolve(false));
350
+ });
351
+ if (ok) return;
352
+ await new Promise((r) => setTimeout(r, 500));
353
+ }
354
+ throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
355
+ }