@dashclaw/cli 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -0
- package/bin/dashclaw.js +36 -1
- package/lib/up/db.js +318 -163
- package/lib/up/index.js +33 -2
- package/package.json +36 -36
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).
|
|
@@ -64,6 +66,15 @@ Stop the local server (and the Docker DB if `up` started it).
|
|
|
64
66
|
npx dashclaw down
|
|
65
67
|
```
|
|
66
68
|
|
|
69
|
+
### `dashclaw import <bundle.json>`
|
|
70
|
+
|
|
71
|
+
Load a workspace carry-out bundle — the file **Export workspace** downloads on a trial's `/connect` card (or `GET /api/workspace/export` on any instance) — into the configured instance. Policies, guard decisions, action records, open loops, assumptions, and agent identities carry over; API keys, OAuth tokens, and secret values never ride a bundle. Idempotent: re-running skips rows that already exist.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
DASHCLAW_BASE_URL=http://localhost:3000 DASHCLAW_API_KEY=oc_live_... \
|
|
75
|
+
dashclaw import dashclaw-workspace-*.json
|
|
76
|
+
```
|
|
77
|
+
|
|
67
78
|
### `dashclaw approvals`
|
|
68
79
|
|
|
69
80
|
Interactive inbox for all pending approval requests. Use arrow keys to navigate, `A` to approve, `D` to deny, `O` to open the replay link, `Q` to quit.
|
|
@@ -84,6 +95,18 @@ Deny a single action by ID.
|
|
|
84
95
|
dashclaw deny act_01h... --reason "Outside change window"
|
|
85
96
|
```
|
|
86
97
|
|
|
98
|
+
### `dashclaw halt`
|
|
99
|
+
|
|
100
|
+
The org kill switch, from the terminal. Requires an admin API key.
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
dashclaw halt on --reason "runaway deploy loop" # every guard evaluation for the org returns block
|
|
104
|
+
dashclaw halt status # who halted, why, since when
|
|
105
|
+
dashclaw halt off # resume normal guard evaluation
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
87
110
|
### `dashclaw install claude`
|
|
88
111
|
|
|
89
112
|
Provision DashClaw governance into Claude Code without cloning the repo.
|
package/bin/dashclaw.js
CHANGED
|
@@ -86,6 +86,8 @@ ${bold('Usage:')}
|
|
|
86
86
|
--port <n> Server port (default: 3000)
|
|
87
87
|
--no-browser Do not open the browser when ready
|
|
88
88
|
dashclaw down Stop the local DashClaw server (and Docker DB if we started it)
|
|
89
|
+
dashclaw import <bundle.json> Import a workspace carry-out bundle (from /api/workspace/export)
|
|
90
|
+
into this instance — idempotent; keys/secrets never ride a bundle
|
|
89
91
|
dashclaw approvals Interactive approval inbox
|
|
90
92
|
dashclaw approve <actionId> [--reason] Approve an action
|
|
91
93
|
dashclaw deny <actionId> [--reason] Deny an action
|
|
@@ -565,6 +567,38 @@ async function cmdInstall() {
|
|
|
565
567
|
}
|
|
566
568
|
}
|
|
567
569
|
|
|
570
|
+
// -- import (v7.2 graduation path) --------------------------------------------
|
|
571
|
+
|
|
572
|
+
/*
|
|
573
|
+
* Ingest a workspace carry-out bundle (the file /api/workspace/export
|
|
574
|
+
* downloads) into the configured instance. HTTP like every other post-up
|
|
575
|
+
* command; the route is idempotent so re-running is safe.
|
|
576
|
+
*/
|
|
577
|
+
async function cmdImport() {
|
|
578
|
+
const file = args[1];
|
|
579
|
+
if (!file || file.startsWith('--')) {
|
|
580
|
+
console.error('Usage: dashclaw import <bundle.json> (the file downloaded from your trial\'s "Export workspace")');
|
|
581
|
+
process.exitCode = 1;
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
let bundle;
|
|
586
|
+
try {
|
|
587
|
+
bundle = JSON.parse(readFileSync(file, 'utf8'));
|
|
588
|
+
} catch (err) {
|
|
589
|
+
throw new Error(err.code === 'ENOENT' ? `File not found: ${file}` : `${file} is not valid JSON`);
|
|
590
|
+
}
|
|
591
|
+
const data = await apiRequest({ baseUrl, apiKey }, 'POST', '/api/workspace/import', { body: bundle });
|
|
592
|
+
console.log(green(`Imported ${data.imported} rows (${data.skipped} skipped — already present or missing their id).`));
|
|
593
|
+
for (const [table, c] of Object.entries(data.counts || {})) {
|
|
594
|
+
console.log(` ${table}: +${c.imported}${c.skipped ? ` (${c.skipped} skipped)` : ''}`);
|
|
595
|
+
}
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.error(red(`Error: ${err.message}`));
|
|
598
|
+
process.exitCode = 1;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
568
602
|
// -- up / down (one-command local install) -----------------------------------
|
|
569
603
|
|
|
570
604
|
async function cmdUp() {
|
|
@@ -1298,7 +1332,7 @@ async function cmdEnv() {
|
|
|
1298
1332
|
|
|
1299
1333
|
// -- Router -------------------------------------------------------------------
|
|
1300
1334
|
|
|
1301
|
-
const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next', 'env', 'halt']);
|
|
1335
|
+
const COMMANDS_NEEDING_CONFIG = new Set(['approvals', 'approve', 'deny', 'doctor', 'code', 'prompts', 'inbox', 'behavior', 'posture', 'next', 'env', 'halt', 'import']);
|
|
1302
1336
|
// `install` deliberately omitted: provisioning hooks and AGENTS.md shouldn't
|
|
1303
1337
|
// require the user to have already configured API keys. If config happens to
|
|
1304
1338
|
// be present, install will pick up baseUrl for the AGENTS.md instance link.
|
|
@@ -1344,6 +1378,7 @@ const COMMAND_HANDLERS = {
|
|
|
1344
1378
|
code: cmdCode,
|
|
1345
1379
|
up: cmdUp,
|
|
1346
1380
|
down: cmdDown,
|
|
1381
|
+
import: cmdImport,
|
|
1347
1382
|
install: cmdInstall,
|
|
1348
1383
|
codex: cmdCodex,
|
|
1349
1384
|
prompts: cmdPrompts,
|
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
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
//
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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.7.0",
|
|
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
|
+
}
|