@dashclaw/cli 0.6.1 → 0.6.2

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/README.md CHANGED
@@ -56,6 +56,8 @@ npx dashclaw up --source-dir <path> # use a local repo checkout instead of the p
56
56
 
57
57
  Re-running `npx dashclaw up` on an existing install boots the server without re-provisioning. Failures checkpoint and resume from where they stopped.
58
58
 
59
+ Postgres prefers host port 5433; when something else already holds it, provisioning scans forward to the first free port and says so (an existing `dashclaw-pg` container keeps whatever port it was created with — data lives in the `dashclaw_pgdata` volume and survives container recreation).
60
+
59
61
  ### `dashclaw down`
60
62
 
61
63
  Stop the local server (and the Docker DB if `up` started it).
@@ -84,6 +86,18 @@ Deny a single action by ID.
84
86
  dashclaw deny act_01h... --reason "Outside change window"
85
87
  ```
86
88
 
89
+ ### `dashclaw halt`
90
+
91
+ The org kill switch, from the terminal. Requires an admin API key.
92
+
93
+ ```bash
94
+ dashclaw halt on --reason "runaway deploy loop" # every guard evaluation for the org returns block
95
+ dashclaw halt status # who halted, why, since when
96
+ dashclaw halt off # resume normal guard evaluation
97
+ ```
98
+
99
+ The halt is absolute at the decision layer (propagates across instances in ~3 s); whether a blocked action is mechanically stopped depends on the surface — see `docs/architecture/enforcement-boundary.md` in the repo.
100
+
87
101
  ### `dashclaw install claude`
88
102
 
89
103
  Provision DashClaw governance into Claude Code without cloning the repo.
package/lib/up/db.js CHANGED
@@ -1,163 +1,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 (port 5433)
7
- // embedded downloads and runs embedded Postgres via embedded-postgres (~40 MB, first run)
8
- // url prompts for a postgresql:// connection string from the user
9
-
10
- import { join } from 'node:path';
11
- import { existsSync, rmSync } from 'node:fs';
12
- import { execFileSync, spawnSync } from 'node:child_process';
13
-
14
- // Keep this in sync with the "embedded-postgres" version in cli/package.json.
15
- const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
16
-
17
- export const LOCAL_DB_URL = 'postgresql://dashclaw:dashclaw@localhost:5433/dashclaw';
18
-
19
- const CONTAINER = 'dashclaw-pg';
20
-
21
- /** Returns true when the `docker` binary is available and responsive. */
22
- export function dockerAvailableSync() {
23
- return spawnSync('docker', ['--version'], { stdio: 'ignore' }).status === 0;
24
- }
25
-
26
- /**
27
- * Determines which database mode to use.
28
- *
29
- * Priority:
30
- * 1. Explicit --db flag
31
- * 2. Non-interactive (--yes): docker if available, otherwise embedded
32
- * 3. Interactive prompt
33
- *
34
- * @param {object} opts
35
- * @param {string|null} opts.flagDb - explicit --db value or null
36
- * @param {boolean} opts.dockerAvailable
37
- * @param {boolean} [opts.yes] - non-interactive mode
38
- * @param {Function} [opts.promptFn] - async (message) => string
39
- * @returns {Promise<'docker'|'embedded'|'url'>}
40
- */
41
- export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn }) {
42
- if (flagDb) return flagDb;
43
- if (yes) return dockerAvailable ? 'docker' : 'embedded';
44
-
45
- const lines = [
46
- 'Database — pick one:',
47
- dockerAvailable
48
- ? ' 1. Docker Postgres (Docker detected) [default]'
49
- : ' 1. Docker Postgres (Docker NOT detected)',
50
- ` 2. Embedded Postgres (no Docker needed, ~40 MB download)${dockerAvailable ? '' : ' [default]'}`,
51
- ' 3. I have a postgresql:// URL',
52
- ];
53
- const def = dockerAvailable ? '1' : '2';
54
- const answer = (await promptFn(`${lines.join('\n')}\nChoice [${def}]: `)).trim() || def;
55
- return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
56
- }
57
-
58
- /**
59
- * Returns the docker run command that starts a local Postgres container.
60
- * Mirrors the repo's docker-compose.yml: postgres:16-alpine, host port 5433,
61
- * dashclaw/dashclaw/dashclaw credentials, named volume dashclaw_pgdata.
62
- */
63
- export function dockerCommandFor() {
64
- return {
65
- cmd: 'docker',
66
- args: [
67
- 'run', '-d', '--name', CONTAINER,
68
- '-e', 'POSTGRES_USER=dashclaw',
69
- '-e', 'POSTGRES_PASSWORD=dashclaw',
70
- '-e', 'POSTGRES_DB=dashclaw',
71
- '-p', '5433:5432',
72
- '-v', 'dashclaw_pgdata:/var/lib/postgresql/data',
73
- 'postgres:16-alpine',
74
- ],
75
- };
76
- }
77
-
78
- /** Starts the dashclaw-pg container, reusing it if it already exists. */
79
- function dockerStartOrRun(logger) {
80
- const ps = spawnSync(
81
- 'docker', ['ps', '-aq', '--filter', `name=^${CONTAINER}$`],
82
- { encoding: 'utf8' },
83
- );
84
- if ((ps.stdout || '').trim()) {
85
- execFileSync('docker', ['start', CONTAINER], { stdio: 'ignore' });
86
- } else {
87
- const { cmd, args } = dockerCommandFor();
88
- execFileSync(cmd, args, { stdio: 'ignore' });
89
- }
90
- logger.error('[ok] Docker Postgres running (container dashclaw-pg, port 5433)');
91
- }
92
-
93
- /**
94
- * Provisions the database according to `mode` and returns { databaseUrl, stop }.
95
- *
96
- * @param {object} opts
97
- * @param {'docker'|'embedded'|'url'} opts.mode
98
- * @param {string} opts.baseDir - base dir for embedded data (e.g. ~/.dashclaw)
99
- * @param {Function} [opts.promptFn] - async (message) => string (required for mode=url)
100
- * @param {object} [opts.logger] - object with .error(); defaults to console
101
- * @returns {Promise<{ databaseUrl: string, stop: () => Promise<void> }>}
102
- */
103
- export async function provisionDatabase({ mode, baseDir, promptFn, logger = console }) {
104
- if (mode === 'url') {
105
- const url = (await promptFn('postgresql:// connection string: ')).trim();
106
- if (!url.startsWith('postgresql://')) throw new Error('That is not a postgresql:// URL.');
107
- return { databaseUrl: url, stop: async () => {} };
108
- }
109
-
110
- if (mode === 'docker') {
111
- dockerStartOrRun(logger);
112
- await waitForPort(5433, 30_000);
113
- return { databaseUrl: LOCAL_DB_URL, stop: async () => {} };
114
- }
115
-
116
- // mode === 'embedded'
117
- const { default: EmbeddedPostgres } = await import('embedded-postgres');
118
- const pgDir = join(baseDir, 'pg');
119
- // Record whether the data dir existed BEFORE this run.
120
- // Only clean up on failure when WE created it (a pre-existing dir means
121
- // a working prior install whose data we must not delete).
122
- const dirPreExisted = existsSync(pgDir);
123
- // Derive credentials from LOCAL_DB_URL so the local dev creds live in
124
- // exactly one place rather than being duplicated as bare literals here.
125
- const localDb = new URL(LOCAL_DB_URL);
126
- const pg = new EmbeddedPostgres({
127
- databaseDir: pgDir,
128
- user: localDb.username,
129
- password: localDb.password,
130
- port: Number(localDb.port),
131
- persistent: true,
132
- });
133
- try {
134
- await pg.initialise();
135
- await pg.start();
136
- } catch (e) {
137
- if (!dirPreExisted) {
138
- rmSync(pgDir, { recursive: true, force: true });
139
- }
140
- throw new Error(
141
- `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${e.message}. Retry with --db docker or --db url.`,
142
- );
143
- }
144
- try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
145
- logger.error('[ok] Embedded Postgres running (port 5433, data in ~/.dashclaw/pg)');
146
- return { databaseUrl: LOCAL_DB_URL, stop: () => pg.stop() };
147
- }
148
-
149
- /** Polls :port until it accepts TCP connections, or throws on timeout. */
150
- async function waitForPort(port, timeoutMs) {
151
- const net = await import('node:net');
152
- const deadline = Date.now() + timeoutMs;
153
- while (Date.now() < deadline) {
154
- const ok = await new Promise((resolve) => {
155
- const s = net.connect(port, '127.0.0.1');
156
- s.once('connect', () => { s.destroy(); resolve(true); });
157
- s.once('error', () => resolve(false));
158
- });
159
- if (ok) return;
160
- await new Promise((r) => setTimeout(r, 500));
161
- }
162
- throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
163
- }
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
+ }
package/lib/up/index.js CHANGED
@@ -13,6 +13,7 @@
13
13
  // without touching the network, Docker, or a real Next server.
14
14
 
15
15
  import { spawnSync } from 'node:child_process';
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
16
17
  import { homedir } from 'node:os';
17
18
  import { join } from 'node:path';
18
19
 
@@ -90,6 +91,24 @@ export function runSetupScriptReal({ appDir, databaseUrl, logger = console, spaw
90
91
  return parsed;
91
92
  }
92
93
 
94
+ /**
95
+ * Rewrites the DATABASE_URL line in the app's .env.local after the local DB
96
+ * legitimately moved ports. Best-effort: a missing file or line is left alone
97
+ * (setup will write it on its next run).
98
+ */
99
+ export function rewriteEnvDatabaseUrl(appDir, databaseUrl, logger = console) {
100
+ const envPath = join(appDir, '.env.local');
101
+ try {
102
+ if (!existsSync(envPath)) return;
103
+ const src = readFileSync(envPath, 'utf8');
104
+ if (!/^DATABASE_URL=.*$/m.test(src)) return;
105
+ writeFileSync(envPath, src.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${databaseUrl}`));
106
+ logger.error(`[ok] Updated DATABASE_URL in ${envPath}`);
107
+ } catch (e) {
108
+ logger.error(`[warn] Could not update ${envPath}: ${e.message} — update DATABASE_URL manually.`);
109
+ }
110
+ }
111
+
93
112
  /** Default process-liveness probe: signal 0 succeeds iff the pid exists. */
94
113
  export function defaultProcessAlive(pid) {
95
114
  try { process.kill(pid, 0); return true; } catch { return false; }
@@ -163,12 +182,24 @@ export async function runUp({ args, baseDir = join(homedir(), '.dashclaw'), deps
163
182
  });
164
183
  const db = (dbMode === 'url' && done('db_ready') && inst.databaseUrl)
165
184
  ? { databaseUrl: inst.databaseUrl, stop: async () => {} }
166
- : await deps.provisionDatabase({ mode: dbMode, baseDir, promptFn: deps.promptFn, logger });
185
+ : await deps.provisionDatabase({
186
+ mode: dbMode, baseDir, promptFn: deps.promptFn, logger,
187
+ savedDatabaseUrl: inst.databaseUrl,
188
+ });
167
189
  if (!done('db_ready')) {
168
190
  inst = saveInstance(baseDir, { dbMode, databaseUrl: db.databaseUrl });
169
191
  inst = checkpoint(baseDir, 'db_ready');
170
192
  }
171
- const databaseUrl = inst.databaseUrl ?? db.databaseUrl;
193
+ // Provisioning is authoritative: if the DB legitimately moved ports (its old
194
+ // port got taken and the container was recreated), chase the move — update
195
+ // the saved URL and the app's .env.local so the server doesn't point at a
196
+ // stranger's Postgres.
197
+ if (db.databaseUrl && inst.databaseUrl && db.databaseUrl !== inst.databaseUrl) {
198
+ logger.error(`[warn] Database URL changed (${inst.databaseUrl} -> ${db.databaseUrl}) — updating saved config.`);
199
+ inst = saveInstance(baseDir, { databaseUrl: db.databaseUrl });
200
+ if (done('setup_done') && appDir) rewriteEnvDatabaseUrl(appDir, db.databaseUrl, logger);
201
+ }
202
+ const databaseUrl = db.databaseUrl ?? inst.databaseUrl;
172
203
 
173
204
  // 4. setup_done -----------------------------------------------------------
174
205
  let apiKey = inst.apiKey;
package/package.json CHANGED
@@ -1,36 +1,36 @@
1
- {
2
- "name": "@dashclaw/cli",
3
- "version": "0.6.1",
4
- "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
- "type": "module",
6
- "keywords": [
7
- "dashclaw",
8
- "cli",
9
- "claude-code",
10
- "ai-governance",
11
- "agent-governance"
12
- ],
13
- "bin": {
14
- "dashclaw": "./bin/dashclaw.js"
15
- },
16
- "files": [
17
- "bin/",
18
- "lib/",
19
- "README.md"
20
- ],
21
- "engines": {
22
- "node": ">=18.0.0"
23
- },
24
- "scripts": {
25
- "test": "node --test test/**/*.test.js"
26
- },
27
- "dependencies": {
28
- "dashclaw": "^2.2.1",
29
- "embedded-postgres": "18.4.0-beta.17",
30
- "tar": "^7.4.0"
31
- },
32
- "license": "MIT",
33
- "publishConfig": {
34
- "access": "public"
35
- }
36
- }
1
+ {
2
+ "name": "@dashclaw/cli",
3
+ "version": "0.6.2",
4
+ "description": "DashClaw terminal client — approve agent actions and diagnose your instance",
5
+ "type": "module",
6
+ "keywords": [
7
+ "dashclaw",
8
+ "cli",
9
+ "claude-code",
10
+ "ai-governance",
11
+ "agent-governance"
12
+ ],
13
+ "bin": {
14
+ "dashclaw": "./bin/dashclaw.js"
15
+ },
16
+ "files": [
17
+ "bin/",
18
+ "lib/",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18.0.0"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test test/**/*.test.js"
26
+ },
27
+ "dependencies": {
28
+ "dashclaw": "^2.2.1",
29
+ "embedded-postgres": "18.4.0-beta.17",
30
+ "tar": "^7.4.0"
31
+ },
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ }
36
+ }