@dashclaw/cli 0.7.0 → 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/claude/install.js +4 -1
- package/lib/local-doctor.js +563 -532
- package/lib/up/db.js +355 -318
- package/lib/up/fetch-app.js +33 -18
- package/package.json +1 -1
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
|
-
|
|
27
|
-
//
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
* 2.
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
'
|
|
181
|
-
'
|
|
182
|
-
],
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
+
}
|