@dashclaw/cli 0.4.0 → 0.6.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.
@@ -0,0 +1,325 @@
1
+ // cli/lib/up/index.js
2
+ //
3
+ // Orchestrator for `npx dashclaw up` — composes the up/ primitives into the
4
+ // full pipeline (fetch → deps → db → setup → build → start → connect → open),
5
+ // checkpointed and resumable via instance.json.
6
+ //
7
+ // Three operating modes fall out of the same loop:
8
+ // fresh — no instance.json: run every step.
9
+ // resume — partial completed[]: skip done steps, continue from the first gap.
10
+ // boot — all six steps done: skip everything up to start, then start+open.
11
+ //
12
+ // Every effect is injectable via `deps` so the orchestrator is unit-testable
13
+ // without touching the network, Docker, or a real Next server.
14
+
15
+ import { spawnSync } from 'node:child_process';
16
+ import { homedir } from 'node:os';
17
+ import { join } from 'node:path';
18
+
19
+ import { STEPS, loadInstance, saveInstance, checkpoint } from './instance.js';
20
+ import { resolveAppVersion, downloadAndExtract } from './fetch-app.js';
21
+ import { dockerAvailableSync, chooseDbMode, provisionDatabase } from './db.js';
22
+ import { installDeps, buildApp, startServer, waitForHealth, openBrowser } from './run.js';
23
+ import { installClaude } from '../claude/install.js';
24
+ import { parseUpArgs } from './args.js';
25
+ import { ask } from '../config.js';
26
+
27
+ /**
28
+ * Kill a process and its entire child tree. On Windows, `process.kill` only
29
+ * terminates the cmd.exe shell wrapper that `shell:true` spawns — taskkill /T
30
+ * reaches the actual Next server underneath.
31
+ */
32
+ export function killTree(pid) {
33
+ if (process.platform === 'win32') {
34
+ spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore' });
35
+ } else {
36
+ process.kill(pid);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Resolve the base state directory from parsed args.
42
+ * Exported so upCommand + cmdDown can unit-test the wiring without spawning.
43
+ */
44
+ export function resolveBaseDir(args) {
45
+ return args.dir ?? join(homedir(), '.dashclaw');
46
+ }
47
+
48
+ /**
49
+ * Run the app's setup script in-process via spawnSync, contract:
50
+ * node --import tsx scripts/setup.mjs --yes --json --database-url <url>
51
+ * prints EXACTLY ONE JSON line: {ok:true, apiKey, adminPassword} or
52
+ * {ok:false, error} with a non-zero exit. We parse the LAST stdout line.
53
+ *
54
+ * @param {object} opts
55
+ * @param {string} opts.appDir
56
+ * @param {string} opts.databaseUrl
57
+ * @param {object} [opts.logger]
58
+ * @param {Function} [opts.spawn] injectable spawnSync for testing (default: spawnSync)
59
+ */
60
+ export function runSetupScriptReal({ appDir, databaseUrl, logger = console, spawn: spawnFn = spawnSync }) {
61
+ logger.error('-> Running setup (migrations + first admin) ...');
62
+ const res = spawnFn(
63
+ 'node',
64
+ [
65
+ '--import', 'tsx', 'scripts/setup.mjs',
66
+ '--yes', '--json', '--database-url', databaseUrl,
67
+ // The orchestrator owns install (step 2) and build (step 5); skip them
68
+ // inside setup.mjs to avoid doing both twice on a fresh install.
69
+ '--skip-install', '--skip-build',
70
+ ],
71
+ // stdin MUST be 'ignore': the default open pipe makes any stray readline
72
+ // prompt in the child hang forever (observed: 12-minute silent hang).
73
+ { cwd: appDir, encoding: 'utf8', shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'] },
74
+ );
75
+ const stdout = res.stdout || '';
76
+ const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
77
+ let parsed = null;
78
+ if (lines.length) {
79
+ try { parsed = JSON.parse(lines[lines.length - 1]); } catch {
80
+ throw new Error(`Setup output was not parseable JSON. Last line: ${lines[lines.length - 1]}`);
81
+ }
82
+ }
83
+ if (res.status !== 0 || parsed?.ok === false) {
84
+ const detail = parsed?.error
85
+ || (res.stderr || '').slice(-2000).trim()
86
+ || `setup exited ${res.status}`;
87
+ throw new Error(`Setup failed: ${detail}`);
88
+ }
89
+ if (!parsed) throw new Error('Setup produced no parseable JSON output.');
90
+ return parsed;
91
+ }
92
+
93
+ /** Default process-liveness probe: signal 0 succeeds iff the pid exists. */
94
+ export function defaultProcessAlive(pid) {
95
+ try { process.kill(pid, 0); return true; } catch { return false; }
96
+ }
97
+
98
+ /** The real effect wiring — swapped wholesale by tests. */
99
+ export function realDeps() {
100
+ return {
101
+ resolveAppVersion,
102
+ downloadAndExtract,
103
+ installDeps,
104
+ chooseDbMode,
105
+ provisionDatabase,
106
+ runSetupScript: runSetupScriptReal,
107
+ buildApp,
108
+ startServer,
109
+ waitForHealth,
110
+ installClaude,
111
+ openBrowser,
112
+ promptFn: ask,
113
+ logger: console,
114
+ dockerAvailable: dockerAvailableSync(),
115
+ processAlive: defaultProcessAlive,
116
+ };
117
+ }
118
+
119
+ /**
120
+ * Run (or resume, or boot) a local DashClaw instance.
121
+ *
122
+ * @param {object} opts
123
+ * @param {object} opts.args parsed up args (yes, noBrowser, db, port, sourceDir, update)
124
+ * @param {string} [opts.baseDir] state + data dir (default ~/.dashclaw)
125
+ * @param {object} [opts.deps] injected effects (default realDeps())
126
+ * @returns {Promise<{ child, stopDb, baseUrl, reusedServer }>}
127
+ */
128
+ export async function runUp({ args, baseDir = join(homedir(), '.dashclaw'), deps = realDeps() }) {
129
+ const { logger } = deps;
130
+
131
+ let inst = loadInstance(baseDir) ?? { completed: [] };
132
+ if (args.update) {
133
+ inst = saveInstance(baseDir, { completed: [] });
134
+ }
135
+ const done = (step) => inst.completed.includes(step);
136
+
137
+ const port = args.port ?? inst.port ?? 3000;
138
+ const baseUrl = `http://localhost:${port}`;
139
+
140
+ // 1. app_fetched ----------------------------------------------------------
141
+ let appDir = inst.appDir;
142
+ if (!done('app_fetched')) {
143
+ const version = args.sourceDir ? 'source' : await deps.resolveAppVersion();
144
+ appDir = args.sourceDir ?? await deps.downloadAndExtract({ version, baseDir, logger });
145
+ inst = saveInstance(baseDir, { version, appDir, port });
146
+ inst = checkpoint(baseDir, 'app_fetched');
147
+ }
148
+
149
+ // 2. deps_installed -------------------------------------------------------
150
+ if (!done('deps_installed')) {
151
+ deps.installDeps(appDir, logger);
152
+ inst = checkpoint(baseDir, 'deps_installed');
153
+ }
154
+
155
+ // 3. db_ready -------------------------------------------------------------
156
+ // provisionDatabase is idempotent and restarts a stopped DB, so we call it
157
+ // every run (including boot) for docker/embedded — but url-mode has no DB
158
+ // process to start, and re-prompting on every resume (or under --yes/non-TTY)
159
+ // is both wrong and hangs. Reuse the saved URL when db_ready is already
160
+ // checkpointed and inst.databaseUrl is set.
161
+ const dbMode = inst.dbMode ?? await deps.chooseDbMode({
162
+ flagDb: args.db, dockerAvailable: deps.dockerAvailable, yes: args.yes, promptFn: deps.promptFn,
163
+ });
164
+ const db = (dbMode === 'url' && done('db_ready') && inst.databaseUrl)
165
+ ? { databaseUrl: inst.databaseUrl, stop: async () => {} }
166
+ : await deps.provisionDatabase({ mode: dbMode, baseDir, promptFn: deps.promptFn, logger });
167
+ if (!done('db_ready')) {
168
+ inst = saveInstance(baseDir, { dbMode, databaseUrl: db.databaseUrl });
169
+ inst = checkpoint(baseDir, 'db_ready');
170
+ }
171
+ const databaseUrl = inst.databaseUrl ?? db.databaseUrl;
172
+
173
+ // 4. setup_done -----------------------------------------------------------
174
+ let apiKey = inst.apiKey;
175
+ if (!done('setup_done')) {
176
+ const out = await deps.runSetupScript({ appDir, databaseUrl, logger });
177
+ if (out.ok === false) throw new Error(out.error || 'Setup failed.');
178
+ apiKey = out.apiKey;
179
+ inst = saveInstance(baseDir, { apiKey });
180
+ // setup.mjs already prints the password once (to stderr) and writes it to
181
+ // .env.local; we deliberately do NOT echo it again here — no secrets twice.
182
+ if (out.adminPassword) {
183
+ logger.error('[ok] First admin created — credentials written to .env.local (printed once).');
184
+ }
185
+ inst = checkpoint(baseDir, 'setup_done');
186
+ }
187
+
188
+ // 5. built ----------------------------------------------------------------
189
+ if (!done('built')) {
190
+ deps.buildApp(appDir, logger);
191
+ inst = checkpoint(baseDir, 'built');
192
+ }
193
+
194
+ // 6. start (skipped when a previously-recorded server pid is still live)
195
+ // Without this check, a second `dashclaw up` would spawn a duplicate
196
+ // `next start` on the same port; the duplicate fails to bind, waitForHealth
197
+ // passes against the ORIGINAL, and upCommand's child.on('exit') fires
198
+ // immediately → stopDb → exits, orphaning the original server.
199
+ let child;
200
+ let reusedServer = false;
201
+ if (inst.pid && deps.processAlive(inst.pid)) {
202
+ try {
203
+ await deps.waitForHealth({ baseUrl });
204
+ // Original server is alive and healthy — reuse it.
205
+ logger.log(`[ok] Reusing server already running on :${port} (pid ${inst.pid})`);
206
+ child = { pid: inst.pid, on: () => {} };
207
+ reusedServer = true;
208
+ } catch {
209
+ // Pid alive but health check failed — fall through to a fresh start.
210
+ }
211
+ }
212
+ if (!reusedServer) {
213
+ child = deps.startServer({ appDir, port, logger });
214
+ inst = saveInstance(baseDir, { pid: child.pid });
215
+ try {
216
+ await deps.waitForHealth({ baseUrl });
217
+ } catch (e) {
218
+ // Health timeout: kill the orphaned server and clear the stale pid so the
219
+ // next `dashclaw up` doesn't try to resume a dead process.
220
+ try { killTree(child.pid); } catch { /* already gone */ }
221
+ saveInstance(baseDir, { pid: null });
222
+ throw e;
223
+ }
224
+ }
225
+ logger.log(`[ok] Server running at ${baseUrl} (Ctrl+C stops it; \`dashclaw up\` restarts it)`);
226
+
227
+ // 7. connected ------------------------------------------------------------
228
+ if (!done('connected')) {
229
+ let connect = true;
230
+ if (!args.yes) {
231
+ const answer = (await deps.promptFn('Connect Claude Code now? [Y/n] ')).trim().toLowerCase();
232
+ connect = answer !== 'n' && answer !== 'no';
233
+ }
234
+ if (connect) {
235
+ await deps.installClaude({ endpoint: baseUrl, apiKey });
236
+ }
237
+ inst = checkpoint(baseDir, 'connected'); // declining is still a completed decision
238
+ }
239
+
240
+ // 8. open -----------------------------------------------------------------
241
+ if (!args.noBrowser) {
242
+ deps.openBrowser(`${baseUrl}/setup`, logger);
243
+ }
244
+ logger.log(`Done. First steps: ${baseUrl}/connect`);
245
+
246
+ return { child, stopDb: db.stop, baseUrl, reusedServer };
247
+ }
248
+
249
+ /**
250
+ * Stop the local instance: kill the server child (if recorded) and stop the
251
+ * Docker container if we started one. Embedded Postgres dies with the up
252
+ * process, so there is nothing to stop for that mode.
253
+ *
254
+ * @param {object} [opts]
255
+ * @param {string} [opts.baseDir] state dir (default ~/.dashclaw)
256
+ * @param {object} [opts.logger] logger (default console)
257
+ * @param {Function} [opts.kill] injectable kill fn for testing (default killTree)
258
+ * @param {Function} [opts.dockerStop] injectable docker-stop fn for testing
259
+ */
260
+ export async function runDown({
261
+ baseDir = join(homedir(), '.dashclaw'),
262
+ logger = console,
263
+ kill = killTree,
264
+ dockerStop = (container) => spawnSync('docker', ['stop', container], { stdio: 'ignore' }),
265
+ } = {}) {
266
+ const inst = loadInstance(baseDir);
267
+ if (!inst) {
268
+ logger.log('No local DashClaw instance recorded — nothing to stop.');
269
+ return;
270
+ }
271
+ if (inst.pid) {
272
+ try {
273
+ kill(inst.pid);
274
+ logger.log(`[ok] Stopped server (pid ${inst.pid}).`);
275
+ } catch {
276
+ logger.log('Server was not running.');
277
+ }
278
+ saveInstance(baseDir, { pid: null });
279
+ }
280
+ if (inst.dbMode === 'docker') {
281
+ // No shell — house decision (see db.js): docker args are passed directly.
282
+ dockerStop('dashclaw-pg');
283
+ logger.log('[ok] Stopped Docker Postgres (container dashclaw-pg).');
284
+ }
285
+ }
286
+
287
+ /**
288
+ * `dashclaw up` entry point: parse args, run the pipeline, and keep the process
289
+ * alive until the server child exits or the user interrupts. SIGINT/SIGTERM
290
+ * stop the DB cleanly before exiting.
291
+ */
292
+ export async function upCommand(argv) {
293
+ const args = parseUpArgs(argv);
294
+ const baseDir = resolveBaseDir(args);
295
+ const { child, stopDb, reusedServer } = await runUp({ args, baseDir });
296
+
297
+ let stopping = false;
298
+ const shutdown = async () => {
299
+ if (stopping) return;
300
+ stopping = true;
301
+ // Kill the server child first — on Windows the recorded pid is a cmd.exe
302
+ // wrapper; killTree reaches the actual Next process via taskkill /T /F.
303
+ // When reusing an existing server we deliberately do NOT kill it: the user
304
+ // did not start it in this session and Ctrl+C should only stop THIS process.
305
+ if (!reusedServer) {
306
+ try { killTree(child.pid); } catch { /* already gone */ }
307
+ }
308
+ await stopDb();
309
+ // Hard exit is intentional here: stopDb has resolved, and we need to
310
+ // ensure the process doesn't linger on SIGINT (especially on Windows where
311
+ // the event loop may keep running after the child exits).
312
+ process.exit(0); // eslint-disable-line no-process-exit
313
+ };
314
+ process.on('SIGINT', shutdown);
315
+ process.on('SIGTERM', shutdown);
316
+
317
+ if (reusedServer) {
318
+ // The reused child stub has a no-op .on(); wait for a signal instead of an
319
+ // exit event that will never fire.
320
+ await new Promise(() => {}); // resolved only by SIGINT/SIGTERM above
321
+ } else {
322
+ await new Promise((resolve) => child.on('exit', resolve));
323
+ await stopDb();
324
+ }
325
+ }
@@ -0,0 +1,46 @@
1
+ // cli/lib/up/instance.js
2
+ //
3
+ // Checkpointed instance state for `dashclaw up`.
4
+ //
5
+ // ~/.dashclaw/instance.json is the resume contract: a fresh run executes steps
6
+ // in STEPS order, checkpointing each; a re-run skips completed steps; corrupt
7
+ // or missing state means a fresh install.
8
+
9
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ /** Canonical step order — the orchestrator re-runs from the first step NOT in `completed`. */
13
+ export const STEPS = ['app_fetched', 'deps_installed', 'db_ready', 'setup_done', 'built', 'connected'];
14
+
15
+ const fileFor = (dir) => join(dir, 'instance.json');
16
+
17
+ /** Returns the parsed instance object, or null if missing or corrupt. */
18
+ export function loadInstance(dir) {
19
+ try {
20
+ const raw = readFileSync(fileFor(dir), 'utf8');
21
+ const parsed = JSON.parse(raw);
22
+ return typeof parsed === 'object' && parsed !== null ? parsed : null;
23
+ } catch {
24
+ return null; // missing or corrupt — caller treats as fresh install
25
+ }
26
+ }
27
+
28
+ /** Merges `data` into the existing instance (or creates it) and writes to disk. */
29
+ export function saveInstance(dir, data) {
30
+ mkdirSync(dir, { recursive: true });
31
+ const current = loadInstance(dir) ?? { completed: [] };
32
+ const next = { ...current, ...data, completed: data.completed ?? current.completed ?? [] };
33
+ // mode 0o600: instance.json stores apiKey + possibly a postgresql:// URL with
34
+ // credentials. No-op on Windows; protects Linux/macOS (matches config.json).
35
+ writeFileSync(fileFor(dir), JSON.stringify(next, null, 2) + '\n', { mode: 0o600 });
36
+ return next;
37
+ }
38
+
39
+ /** Appends `step` to `completed` (idempotent) and writes to disk. */
40
+ export function checkpoint(dir, step) {
41
+ mkdirSync(dir, { recursive: true });
42
+ const inst = loadInstance(dir) ?? { completed: [] };
43
+ if (!inst.completed.includes(step)) inst.completed.push(step);
44
+ writeFileSync(fileFor(dir), JSON.stringify(inst, null, 2) + '\n', { mode: 0o600 });
45
+ return inst;
46
+ }
package/lib/up/run.js ADDED
@@ -0,0 +1,52 @@
1
+ // cli/lib/up/run.js
2
+ //
3
+ // Build, start, health-wait, and open-browser primitives for `npx dashclaw up`.
4
+
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+
7
+ const shell = process.platform === 'win32';
8
+
9
+ function npm(args, cwd, logger, runner = spawnSync) {
10
+ logger.error(`-> npm ${args.join(' ')}`);
11
+ const res = runner('npm', args, { cwd, stdio: ['ignore', 'inherit', 'inherit'], shell });
12
+ if (res.status !== 0) throw new Error(`npm ${args[0]} failed (exit ${res.status}). Re-run \`npx dashclaw up\` to resume from this step.`);
13
+ }
14
+
15
+ export function installDeps(appDir, logger = console, runner = spawnSync) {
16
+ try { npm(['ci', '--no-audit', '--no-fund'], appDir, logger, runner); }
17
+ catch { npm(['install', '--no-audit', '--no-fund'], appDir, logger, runner); } // lockfile mismatch fallback
18
+ }
19
+
20
+ export function buildApp(appDir, logger = console, runner = spawnSync) {
21
+ npm(['run', 'build'], appDir, logger, runner);
22
+ }
23
+
24
+ /** Start `next start` as a child; .env.local in appDir is loaded by Next itself. */
25
+ export function startServer({ appDir, port, logger = console }) {
26
+ logger.error(`-> Starting server on :${port}`);
27
+ const child = spawn('npx', ['next', 'start', '-p', String(port)], {
28
+ cwd: appDir, stdio: ['ignore', 'inherit', 'inherit'], shell,
29
+ });
30
+ return child;
31
+ }
32
+
33
+ export async function waitForHealth({ baseUrl, fetchImpl = fetch, timeoutMs = 60_000, intervalMs = 1000 }) {
34
+ const deadline = Date.now() + timeoutMs;
35
+ let lastStatus = 'no response';
36
+ while (Date.now() < deadline) {
37
+ try {
38
+ const res = await fetchImpl(`${baseUrl}/api/health`);
39
+ if (res.status === 200) return;
40
+ lastStatus = res.status;
41
+ } catch { lastStatus = 'connection refused'; }
42
+ await new Promise((r) => setTimeout(r, intervalMs));
43
+ }
44
+ throw new Error(`Server health check failed: /api/health last answered ${lastStatus}. Check the server output above.`);
45
+ }
46
+
47
+ export function openBrowser(url, logger = console) {
48
+ const cmd = process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
49
+ : process.platform === 'darwin' ? ['open', [url]] : ['xdg-open', [url]];
50
+ try { spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true }).unref(); }
51
+ catch { logger.error(`Open ${url} in your browser.`); }
52
+ }
package/package.json CHANGED
@@ -1,34 +1,36 @@
1
- {
2
- "name": "@dashclaw/cli",
3
- "version": "0.4.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
- },
30
- "license": "MIT",
31
- "publishConfig": {
32
- "access": "public"
33
- }
34
- }
1
+ {
2
+ "name": "@dashclaw/cli",
3
+ "version": "0.6.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
+ }