@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.
- package/README.md +210 -183
- package/bin/dashclaw.js +88 -2
- package/lib/claude/install.js +23 -10
- package/lib/codex/install.js +27 -6
- package/lib/config.js +49 -2
- package/lib/up/args.js +25 -0
- package/lib/up/db.js +163 -0
- package/lib/up/fetch-app.js +82 -0
- package/lib/up/index.js +325 -0
- package/lib/up/instance.js +46 -0
- package/lib/up/run.js +52 -0
- package/package.json +36 -34
package/lib/up/index.js
ADDED
|
@@ -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
|
-
"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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
}
|