@dashclaw/cli 0.7.1 → 0.7.4

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,560 @@
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 useusing 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 { tmpdir } from 'node:os';
21
+ import { existsSync, rmSync, writeFileSync } from 'node:fs';
22
+ import { execFileSync, spawnSync } from 'node:child_process';
23
+
24
+ // Keep this in sync with the "embedded-postgres" version in cli/package.json.
25
+ const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
26
+
27
+ // Force a UTF-8 cluster regardless of host locale. Without this, initdb on
28
+ // Windows inherits the OS locale (e.g. English_United States.1252) and
29
+ // creates a WIN1252-encoded database — DashClaw's migration SQL is UTF-8 and
30
+ // statements containing characters with no WIN1252 equivalent hard-fail with
31
+ // 22P05, leaving a partial schema (observed live in Windows Sandbox).
32
+ const INITDB_FLAGS = ['--encoding=UTF8', '--no-locale'];
33
+
34
+ // Windows exit status 0xC0000135 (STATUS_DLL_NOT_FOUND). The embedded
35
+ // Postgres binaries link against the Microsoft Visual C++ runtime, which a
36
+ // fresh Windows install (and Windows Sandbox) does not ship — initdb dies at
37
+ // exec with this code and EMPTY stderr, which reads as pure noise to a
38
+ // first-run user.
39
+ const STATUS_DLL_NOT_FOUND = '3221225781';
40
+ const VC_REDIST_URL = 'https://aka.ms/vs/17/release/vc_redist.x64.exe';
41
+ const VC_REDIST_HINT =
42
+ 'Embedded Postgres needs the Microsoft Visual C++ runtime, which this Windows machine does not have. '
43
+ + `Install it once from ${VC_REDIST_URL} (or \`winget install Microsoft.VCRedist.2015+.x64\`), `
44
+ + 'then re-run `npx dashclaw up`. Alternatively retry with --db docker or --db url.';
45
+
46
+ // Redistributable installer exit codes that mean the runtime is (or already
47
+ // was) in place: 0 = installed, 1638 = a newer version is already installed,
48
+ // 3010 = installed, reboot pending (the DLLs are on disk and loadable now).
49
+ const VC_REDIST_OK_EXIT_CODES = new Set([0, 1638, 3010]);
50
+
51
+ /**
52
+ * Downloads and silently installs the Microsoft Visual C++ redistributable so
53
+ * `npx dashclaw up` stays one command on a fresh Windows machine. Elevation
54
+ * goes through `Start-Process -Verb RunAs`: an already-elevated shell runs it
55
+ * directly, a standard shell surfaces the Windows UAC consent dialog — that
56
+ * dialog IS the user's approval for the system-wide install. Throws (with the
57
+ * manual remediation appended) when the download or install fails; the caller
58
+ * re-checks the DLLs afterwards as the authoritative success signal.
59
+ */
60
+ export async function installVcRedist({
61
+ logger = console,
62
+ fetchFn = fetch,
63
+ spawn = spawnSync,
64
+ downloadDir = tmpdir(),
65
+ } = {}) {
66
+ logger.error(
67
+ '[..] Embedded Postgres needs the Microsoft Visual C++ runtime — installing it now '
68
+ + '(one-time, ~25 MB from microsoft.com; Windows may show an admin consent prompt) ...',
69
+ );
70
+ const exePath = join(downloadDir, 'dashclaw-vc_redist.x64.exe');
71
+ let res;
72
+ try {
73
+ res = await fetchFn(VC_REDIST_URL);
74
+ } catch (e) {
75
+ throw new Error(`VC++ runtime download failed (${e.message}). ${VC_REDIST_HINT}`);
76
+ }
77
+ if (!res.ok) {
78
+ throw new Error(`VC++ runtime download failed (HTTP ${res.status}). ${VC_REDIST_HINT}`);
79
+ }
80
+ writeFileSync(exePath, Buffer.from(await res.arrayBuffer()));
81
+ const psPath = exePath.replace(/'/g, "''");
82
+ const script =
83
+ `$p = Start-Process -FilePath '${psPath}' -ArgumentList '/install','/quiet','/norestart' -Verb RunAs -Wait -PassThru; `
84
+ + 'exit $p.ExitCode';
85
+ const run = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { stdio: 'ignore' });
86
+ if (run.error) {
87
+ throw new Error(`VC++ runtime install could not start (${run.error.message}). ${VC_REDIST_HINT}`);
88
+ }
89
+ if (!VC_REDIST_OK_EXIT_CODES.has(run.status)) {
90
+ throw new Error(
91
+ `VC++ runtime installer exited with code ${run.status} (declined admin prompt, or install failure). ${VC_REDIST_HINT}`,
92
+ );
93
+ }
94
+ logger.error('[ok] Microsoft Visual C++ runtime installed.');
95
+ }
96
+
97
+ /**
98
+ * True on Windows machines missing the VC++ runtime DLLs the embedded
99
+ * Postgres binaries need. Checked BEFORE the embedded attempt so the failure
100
+ * is actionable instead of a raw 0xC0000135 with empty stderr.
101
+ */
102
+ export function missingVcRuntime({ platform = process.platform, exists = existsSync } = {}) {
103
+ if (platform !== 'win32') return false;
104
+ const sys32 = join(process.env.SystemRoot || 'C:\\Windows', 'System32');
105
+ return !(exists(join(sys32, 'vcruntime140.dll')) && exists(join(sys32, 'msvcp140.dll')));
106
+ }
107
+
108
+ /**
109
+ * Renders an embedded-postgres failure as an actionable message. Maps the
110
+ * Windows DLL-not-found exit code to the VC++ runtime remediation even when
111
+ * the preflight passed (a different DLL may be the missing one).
112
+ */
113
+ export function embeddedFailureMessage(err) {
114
+ // embedded-postgres rejects with NO value when the postgres process exits
115
+ // before it is ready (its start() does `reject()` bare) — the real reason is
116
+ // in the log lines it already printed. Guard, or this helper itself crashes
117
+ // with "Cannot read properties of undefined" and eats the actual error
118
+ // (observed live in Windows Sandbox).
119
+ const detail = err?.message
120
+ ?? 'the Postgres process exited before it was ready the reason is in the log lines above';
121
+ const base = `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${detail}`;
122
+ if (String(detail).includes(STATUS_DLL_NOT_FOUND)) {
123
+ return `${base} — this exit code means a required system DLL is missing. ${VC_REDIST_HINT}`;
124
+ }
125
+ return `${base}. Retry with --db docker or --db url.`;
126
+ }
127
+
128
+ export const DEFAULT_DB_PORT = 5433;
129
+ // Scan window when the preferred port is taken: preferred+1 .. preferred+10.
130
+ const PORT_SCAN_SPAN = 10;
131
+
132
+ export function localDbUrlFor(port) {
133
+ return `postgresql://dashclaw:dashclaw@localhost:${port}/dashclaw`;
134
+ }
135
+
136
+ // Back-compat export (the default-port URL).
137
+ export const LOCAL_DB_URL = localDbUrlFor(DEFAULT_DB_PORT);
138
+
139
+ const CONTAINER = 'dashclaw-pg';
140
+
141
+ /** Returns true when the `docker` binary is available and responsive. */
142
+ export function dockerAvailableSync() {
143
+ return spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0;
144
+ }
145
+
146
+ /**
147
+ * Determines which database mode to use.
148
+ *
149
+ * Priority:
150
+ * 1. Explicit --db flag
151
+ * 2. Non-interactive (--yes): docker if available, otherwise embedded
152
+ * 3. Interactive prompt
153
+ *
154
+ * @param {object} opts
155
+ * @param {string|null} opts.flagDb - explicit --db value or null
156
+ * @param {boolean} opts.dockerAvailable
157
+ * @param {boolean} [opts.yes] - non-interactive mode
158
+ * @param {Function} [opts.promptFn] - async (message) => string
159
+ * @returns {Promise<'docker'|'embedded'|'url'>}
160
+ */
161
+ export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn, logger = console }) {
162
+ if (flagDb) return flagDb;
163
+ if (yes) return dockerAvailable ? 'docker' : 'embedded';
164
+
165
+ const lines = [
166
+ 'Database — pick one:',
167
+ dockerAvailable
168
+ ? ' 1. Docker Postgres (Docker detected) [default]'
169
+ : ' 1. Docker Postgres (Docker NOT detected)',
170
+ ` 2. Embedded Postgres (no Docker needed, ~40 MB download)${dockerAvailable ? '' : ' [default]'}`,
171
+ ' 3. I have a postgresql:// URL',
172
+ ];
173
+ const def = dockerAvailable ? '1' : '2';
174
+ // The menu is printed OUTSIDE the readline prompt: on Windows, readline
175
+ // re-renders multi-line prompts on input events, so the whole menu was
176
+ // echoed twice (observed live in Windows Sandbox). Only the one-line
177
+ // "Choice" question goes through promptFn.
178
+ logger.error(lines.join('\n'));
179
+ const answer = (await promptFn(`Choice [${def}]: `)).trim() || def;
180
+ return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
181
+ }
182
+
183
+ /**
184
+ * True when nothing on this machine is serving :port.
185
+ *
186
+ * Two probes, both required — a single one lies on Windows, where binding
187
+ * 127.0.0.1:port SUCCEEDS while another process holds 0.0.0.0:port (observed
188
+ * live: the probe said 5433 was free while a Docker proxy held it):
189
+ * 1. connect() to 127.0.0.1:port — an accepted connection means a live
190
+ * listener regardless of which address it bound.
191
+ * 2. listen() on the wildcard address — fails when the port is held at
192
+ * 0.0.0.0/[::] even if nothing accepts on loopback.
193
+ */
194
+ export async function isPortFree(port) {
195
+ const net = await import('node:net');
196
+ const accepts = await new Promise((resolve) => {
197
+ const s = net.connect({ port, host: '127.0.0.1' });
198
+ const done = (v) => { s.destroy(); resolve(v); };
199
+ s.once('connect', () => done(true));
200
+ s.once('error', () => done(false));
201
+ s.setTimeout(1000, () => done(false));
202
+ });
203
+ if (accepts) return false;
204
+ return new Promise((resolve) => {
205
+ const srv = net.createServer();
206
+ srv.once('error', () => resolve(false));
207
+ srv.once('listening', () => srv.close(() => resolve(true)));
208
+ srv.listen(port); // wildcard bind on purpose — see above
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Picks the host port for a locally provisioned Postgres: the preferred port
214
+ * if free, otherwise the first free port in the scan window (logged, so the
215
+ * deviation is never silent). Throws when the whole window is occupied.
216
+ *
217
+ * @param {object} [opts]
218
+ * @param {number} [opts.preferred=DEFAULT_DB_PORT]
219
+ * @param {Function} [opts.isFree=isPortFree] injectable for tests
220
+ * @param {object} [opts.logger]
221
+ * @returns {Promise<number>}
222
+ */
223
+ export async function pickDbPort({ preferred = DEFAULT_DB_PORT, isFree = isPortFree, logger = console } = {}) {
224
+ if (await isFree(preferred)) return preferred;
225
+ for (let p = preferred + 1; p <= preferred + PORT_SCAN_SPAN; p++) {
226
+ if (await isFree(p)) {
227
+ logger.error(`[warn] Port ${preferred} is already in use — using free port ${p} for Postgres instead.`);
228
+ return p;
229
+ }
230
+ }
231
+ throw new Error(
232
+ `No free port for Postgres: ${preferred}-${preferred + PORT_SCAN_SPAN} are all in use. `
233
+ + 'Free one up, or re-run with --db url and your own postgresql:// connection string.',
234
+ );
235
+ }
236
+
237
+ /**
238
+ * Returns the docker run command that starts a local Postgres container.
239
+ * Mirrors the repo's docker-compose.yml: postgres:16-alpine, dashclaw/dashclaw/
240
+ * dashclaw credentials, named volume dashclaw_pgdata. Host port is a parameter
241
+ * (default 5433) so provisioning can route around an occupied port.
242
+ */
243
+ export function dockerCommandFor(port = DEFAULT_DB_PORT) {
244
+ return {
245
+ cmd: 'docker',
246
+ args: [
247
+ 'run', '-d', '--name', CONTAINER,
248
+ '-e', 'POSTGRES_USER=dashclaw',
249
+ '-e', 'POSTGRES_PASSWORD=dashclaw',
250
+ '-e', 'POSTGRES_DB=dashclaw',
251
+ '-p', `${port}:5432`,
252
+ '-v', 'dashclaw_pgdata:/var/lib/postgresql/data',
253
+ 'postgres:16-alpine',
254
+ ],
255
+ };
256
+ }
257
+
258
+ /** Runs a docker CLI call, capturing stderr so failures are explainable. */
259
+ function dockerExec(args) {
260
+ try {
261
+ return execFileSync('docker', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
262
+ } catch (e) {
263
+ const stderr = (e.stderr || '').toString().trim();
264
+ throw new Error(`docker ${args[0]} failed${stderr ? `: ${stderr}` : `: ${e.message}`}`);
265
+ }
266
+ }
267
+
268
+ /** The real docker effects — injectable in provisionDatabase for tests. */
269
+ export const realDockerOps = {
270
+ /** Container id (any state), or '' when it does not exist. */
271
+ containerId: () => (spawnSync(
272
+ 'docker', ['ps', '-aq', '--filter', `name=^${CONTAINER}$`],
273
+ { encoding: 'utf8' },
274
+ ).stdout || '').trim(),
275
+ /** Container id when RUNNING, or ''. */
276
+ runningId: () => (spawnSync(
277
+ 'docker', ['ps', '-q', '--filter', `name=^${CONTAINER}$`],
278
+ { encoding: 'utf8' },
279
+ ).stdout || '').trim(),
280
+ /**
281
+ * Host port the existing container maps to 5432, or null. Reads the
282
+ * configured binding via `docker inspect` because `docker port` reports
283
+ * nothing for containers that are created/stopped rather than running.
284
+ */
285
+ mappedPort: () => {
286
+ const out = spawnSync('docker', [
287
+ 'inspect', CONTAINER,
288
+ '--format', '{{(index (index .HostConfig.PortBindings "5432/tcp") 0).HostPort}}',
289
+ ], { encoding: 'utf8' }).stdout || '';
290
+ const m = /^(\d+)$/.exec(out.trim());
291
+ return m ? Number(m[1]) : null;
292
+ },
293
+ start: () => dockerExec(['start', CONTAINER]),
294
+ remove: () => dockerExec(['rm', CONTAINER]),
295
+ run: (port) => {
296
+ try {
297
+ dockerExec(dockerCommandFor(port).args);
298
+ } catch (e) {
299
+ // A failed `docker run` can leave a half-created container behind,
300
+ // which would poison every retry with "name already in use" — clean
301
+ // it up best-effort before surfacing the real error.
302
+ try { execFileSync('docker', ['rm', '-f', CONTAINER], { stdio: 'ignore' }); } catch { /* nothing to clean */ }
303
+ throw e;
304
+ }
305
+ },
306
+ };
307
+
308
+ /**
309
+ * Starts (or creates) the dashclaw-pg container and returns the host port it
310
+ * serves on. Continuity first: a running container is used as-is on whatever
311
+ * port it maps; a stopped container is restarted on its recorded port. Only
312
+ * when that recorded port has been taken by some OTHER process is the
313
+ * container recreated on a fresh free port — safe by construction, because
314
+ * all data lives in the named volume `dashclaw_pgdata`, not the container.
315
+ */
316
+ async function dockerProvision({ preferredPort, ops, isFree, logger }) {
317
+ if (ops.containerId()) {
318
+ const mapped = ops.mappedPort();
319
+ if (ops.runningId() && mapped) {
320
+ logger.error(`[ok] Docker Postgres already running (container ${CONTAINER}, port ${mapped})`);
321
+ return mapped;
322
+ }
323
+ if (mapped && await isFree(mapped)) {
324
+ ops.start();
325
+ logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${mapped})`);
326
+ return mapped;
327
+ }
328
+ // The container's port is now held by something else (or the mapping is
329
+ // unreadable) — recreate it on a free port. Data survives in the volume.
330
+ logger.error(
331
+ `[warn] Container ${CONTAINER} maps port ${mapped ?? '?'} which is now in use by another process — `
332
+ + 'recreating the container on a free port (data volume dashclaw_pgdata is preserved).',
333
+ );
334
+ ops.remove();
335
+ }
336
+ const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
337
+ ops.run(port);
338
+ logger.error(`[ok] Docker Postgres running (container ${CONTAINER}, port ${port})`);
339
+ return port;
340
+ }
341
+
342
+ /**
343
+ * Provisions the database according to `mode` and returns { databaseUrl, stop }.
344
+ *
345
+ * @param {object} opts
346
+ * @param {'docker'|'embedded'|'url'} opts.mode
347
+ * @param {string} opts.baseDir - base dir for embedded data (e.g. ~/.dashclaw)
348
+ * @param {Function} [opts.promptFn] - async (message) => string (required for mode=url)
349
+ * @param {string} [opts.savedDatabaseUrl] - databaseUrl from a prior run (port continuity)
350
+ * @param {object} [opts.dockerOps] - injectable docker effects (default realDockerOps)
351
+ * @param {Function} [opts.isFree] - injectable port probe (default isPortFree)
352
+ * @param {Function} [opts.waitForDbPort] - injectable readiness wait (default waitForPort)
353
+ * @param {object} [opts.logger] - object with .error(); defaults to console
354
+ * @returns {Promise<{ databaseUrl: string, stop: () => Promise<void> }>}
355
+ */
356
+ export async function provisionDatabase({
357
+ mode, baseDir, promptFn, savedDatabaseUrl,
358
+ dockerOps = realDockerOps, isFree = isPortFree, waitForDbPort = waitForPort, logger = console,
359
+ checkVcRuntime = missingVcRuntime, installVc = installVcRedist,
360
+ }) {
361
+ let preferredPort = DEFAULT_DB_PORT;
362
+ if (savedDatabaseUrl) {
363
+ try { preferredPort = Number(new URL(savedDatabaseUrl).port) || DEFAULT_DB_PORT; } catch { /* keep default */ }
364
+ }
365
+
366
+ if (mode === 'url') {
367
+ const url = (await promptFn('postgresql:// connection string: ')).trim();
368
+ if (!url.startsWith('postgresql://')) throw new Error('That is not a postgresql:// URL.');
369
+ return { databaseUrl: url, stop: async () => {} };
370
+ }
371
+
372
+ if (mode === 'docker') {
373
+ const port = await dockerProvision({ preferredPort, ops: dockerOps, isFree, logger });
374
+ await waitForDbPort(port, 30_000);
375
+ return { databaseUrl: localDbUrlFor(port), stop: async () => {} };
376
+ }
377
+
378
+ // mode === 'embedded'
379
+ if (checkVcRuntime()) {
380
+ // Fresh Windows machines (and Windows Sandbox) don't ship the VC++
381
+ // runtime the Postgres binaries link against. Install it in-flow instead
382
+ // of failing with homework — `dashclaw up` promises ONE command.
383
+ await installVc({ logger });
384
+ if (checkVcRuntime()) {
385
+ throw new Error(`The VC++ runtime installer finished but the runtime DLLs are still missing. ${VC_REDIST_HINT}`);
386
+ }
387
+ }
388
+ const pgDir = join(baseDir, 'pg');
389
+ if (process.platform === 'win32') {
390
+ // postgres.exe hard-refuses an elevated (admin) token — the norm in
391
+ // Windows Sandbox and admin terminals. pg_ctl creates the restricted
392
+ // token PostgreSQL requires, so on Windows the server lifecycle goes
393
+ // through pg_ctl instead of embedded-postgres spawning postgres directly.
394
+ return provisionEmbeddedWindows({ baseDir, pgDir, preferredPort, isFree, logger });
395
+ }
396
+ const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
397
+ const { default: EmbeddedPostgres } = await import('embedded-postgres');
398
+ // Record whether the data dir existed BEFORE this run.
399
+ // Only clean up on failure when WE created it (a pre-existing dir means
400
+ // a working prior install whose data we must not delete).
401
+ const dirPreExisted = existsSync(pgDir);
402
+ // Derive credentials from LOCAL_DB_URL so the local dev creds live in
403
+ // exactly one place rather than being duplicated as bare literals here.
404
+ const localDb = new URL(LOCAL_DB_URL);
405
+ const pg = new EmbeddedPostgres({
406
+ databaseDir: pgDir,
407
+ user: localDb.username,
408
+ password: localDb.password,
409
+ port,
410
+ persistent: true,
411
+ initdbFlags: INITDB_FLAGS,
412
+ });
413
+ try {
414
+ // initdb refuses a non-empty data dir, so only initialise a cluster that
415
+ // does not exist yet (PG_VERSION marks an initialised cluster) — without
416
+ // this, every resumed `up` with an embedded DB fails at re-init.
417
+ if (!clusterInitialised(pgDir)) await pg.initialise();
418
+ await pg.start();
419
+ } catch (e) {
420
+ if (!dirPreExisted) {
421
+ rmSync(pgDir, { recursive: true, force: true });
422
+ }
423
+ throw new Error(embeddedFailureMessage(e));
424
+ }
425
+ try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
426
+ logger.error(`[ok] Embedded Postgres running (port ${port}, data in ${pgDir})`);
427
+ return { databaseUrl: localDbUrlFor(port), stop: () => pg.stop() };
428
+ }
429
+
430
+ /** True when pgDir already holds an initialised cluster (initdb completed). */
431
+ export function clusterInitialised(pgDir, exists = existsSync) {
432
+ return exists(join(pgDir, 'PG_VERSION'));
433
+ }
434
+
435
+ /** Last lines of the pg_ctl server log, for actionable start-failure errors. */
436
+ async function pgLogTail(logFile, lines = 12) {
437
+ try {
438
+ const { readFileSync } = await import('node:fs');
439
+ const content = readFileSync(logFile, 'utf8').trim().split(/\r?\n/);
440
+ return ` Server log (${logFile}):\n${content.slice(-lines).join('\n')}`;
441
+ } catch {
442
+ return '';
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Windows lifecycle for the embedded cluster, via pg_ctl.
448
+ *
449
+ * Why not embedded-postgres's own start(): it spawns postgres.exe directly,
450
+ * and postgres.exe refuses to run under a token with admin privileges
451
+ * ("Execution of PostgreSQL by a user with administrative permissions is not
452
+ * permitted"). initdb self-restricts its token, pg_ctl restricts the token it
453
+ * starts postgres with — postgres itself does not. Routing start/stop through
454
+ * pg_ctl works from BOTH elevated and normal shells. Consequence: the server
455
+ * is detached from this process, so a previous `up` may have left it running —
456
+ * `pg_ctl status` first, and reuse it on its saved port.
457
+ *
458
+ * All effects are injectable for unit tests.
459
+ */
460
+ export async function provisionEmbeddedWindows({
461
+ baseDir, pgDir, preferredPort, isFree = isPortFree, logger = console,
462
+ bins, // { pg_ctl } — default: @embedded-postgres/windows-x64
463
+ spawn = spawnSync,
464
+ initCluster, // default: embedded-postgres initialise()
465
+ connectClient, // default: pg Client — async (port) => { query, end }
466
+ }) {
467
+ const localDb = new URL(LOCAL_DB_URL);
468
+ const pgCtl = bins?.pg_ctl ?? (await import('@embedded-postgres/windows-x64')).pg_ctl;
469
+ const logFile = join(baseDir, 'pg.log');
470
+
471
+ const running = spawn(pgCtl, ['-D', pgDir, 'status'], { stdio: 'ignore' }).status === 0;
472
+ let port = preferredPort;
473
+ if (running) {
474
+ // A detached server from a previous `up` is still serving — reuse it on
475
+ // the port it was started with (the saved databaseUrl's port). Probing
476
+ // for a free port here would wrongly route around our own server.
477
+ logger.error(`[ok] Embedded Postgres already running (port ${port}, data in ${pgDir})`);
478
+ } else {
479
+ port = await pickDbPort({ preferred: preferredPort, isFree, logger });
480
+ const dirPreExisted = existsSync(pgDir);
481
+ try {
482
+ if (!clusterInitialised(pgDir)) {
483
+ if (initCluster) {
484
+ await initCluster({ pgDir, user: localDb.username, password: localDb.password });
485
+ } else {
486
+ const { default: EmbeddedPostgres } = await import('embedded-postgres');
487
+ await new EmbeddedPostgres({
488
+ databaseDir: pgDir,
489
+ user: localDb.username,
490
+ password: localDb.password,
491
+ port,
492
+ persistent: true,
493
+ initdbFlags: INITDB_FLAGS,
494
+ }).initialise();
495
+ }
496
+ }
497
+ const res = spawn(
498
+ pgCtl,
499
+ ['-D', pgDir, '-o', `-p ${port}`, '-l', logFile, '-w', '-t', '60', 'start'],
500
+ { stdio: 'ignore' },
501
+ );
502
+ if (res.error || res.status !== 0) {
503
+ const reason = res.error ? res.error.message : `pg_ctl start exited ${res.status}.`;
504
+ throw new Error(`${reason}${await pgLogTail(logFile)}`);
505
+ }
506
+ } catch (e) {
507
+ if (!dirPreExisted) {
508
+ rmSync(pgDir, { recursive: true, force: true });
509
+ }
510
+ throw new Error(embeddedFailureMessage(e));
511
+ }
512
+ logger.error(`[ok] Embedded Postgres running (port ${port}, data in ${pgDir})`);
513
+ }
514
+
515
+ await createDashclawDatabase({ port, localDb, connectClient });
516
+ return {
517
+ databaseUrl: localDbUrlFor(port),
518
+ stop: async () => {
519
+ spawn(pgCtl, ['-D', pgDir, '-m', 'fast', 'stop'], { stdio: 'ignore' });
520
+ },
521
+ };
522
+ }
523
+
524
+ /** CREATE DATABASE dashclaw, tolerating "already exists" (resume). */
525
+ async function createDashclawDatabase({ port, localDb, connectClient }) {
526
+ const connect = connectClient ?? (async (p) => {
527
+ const { default: pg } = await import('pg');
528
+ const client = new pg.Client({
529
+ host: 'localhost', port: p,
530
+ user: localDb.username, password: localDb.password,
531
+ database: 'postgres',
532
+ });
533
+ await client.connect();
534
+ return client;
535
+ });
536
+ const client = await connect(port);
537
+ try {
538
+ await client.query(`CREATE DATABASE ${localDb.pathname.slice(1)}`);
539
+ } catch (e) {
540
+ if (e.code !== '42P04') throw e; // 42P04 = duplicate_database (resume)
541
+ } finally {
542
+ await client.end();
543
+ }
544
+ }
545
+
546
+ /** Polls :port until it accepts TCP connections, or throws on timeout. */
547
+ async function waitForPort(port, timeoutMs) {
548
+ const net = await import('node:net');
549
+ const deadline = Date.now() + timeoutMs;
550
+ while (Date.now() < deadline) {
551
+ const ok = await new Promise((resolve) => {
552
+ const s = net.connect(port, '127.0.0.1');
553
+ s.once('connect', () => { s.destroy(); resolve(true); });
554
+ s.once('error', () => resolve(false));
555
+ });
556
+ if (ok) return;
557
+ await new Promise((r) => setTimeout(r, 500));
558
+ }
559
+ throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
560
+ }