@dashclaw/cli 0.4.0 → 0.5.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/lib/up/db.js ADDED
@@ -0,0 +1,160 @@
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
+ const pg = new EmbeddedPostgres({
124
+ databaseDir: pgDir,
125
+ user: 'dashclaw',
126
+ password: 'dashclaw',
127
+ port: 5433,
128
+ persistent: true,
129
+ });
130
+ try {
131
+ await pg.initialise();
132
+ await pg.start();
133
+ } catch (e) {
134
+ if (!dirPreExisted) {
135
+ rmSync(pgDir, { recursive: true, force: true });
136
+ }
137
+ throw new Error(
138
+ `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${e.message}. Retry with --db docker or --db url.`,
139
+ );
140
+ }
141
+ try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
142
+ logger.error('[ok] Embedded Postgres running (port 5433, data in ~/.dashclaw/pg)');
143
+ return { databaseUrl: LOCAL_DB_URL, stop: () => pg.stop() };
144
+ }
145
+
146
+ /** Polls :port until it accepts TCP connections, or throws on timeout. */
147
+ async function waitForPort(port, timeoutMs) {
148
+ const net = await import('node:net');
149
+ const deadline = Date.now() + timeoutMs;
150
+ while (Date.now() < deadline) {
151
+ const ok = await new Promise((resolve) => {
152
+ const s = net.connect(port, '127.0.0.1');
153
+ s.once('connect', () => { s.destroy(); resolve(true); });
154
+ s.once('error', () => resolve(false));
155
+ });
156
+ if (ok) return;
157
+ await new Promise((r) => setTimeout(r, 500));
158
+ }
159
+ throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
160
+ }
@@ -0,0 +1,82 @@
1
+ // cli/lib/up/fetch-app.js
2
+ //
3
+ // Version resolve + tarball fetch/extract for `dashclaw up`.
4
+ // Fetches the latest platform version from npm and downloads the corresponding
5
+ // GitHub release tarball, extracting it into ${baseDir}/app/${version}/.
6
+
7
+ import { mkdirSync, existsSync, rmSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { pipeline } from 'node:stream/promises';
10
+ import { Readable } from 'node:stream';
11
+ import * as tar from 'tar';
12
+
13
+ const REPO = 'ucsandman/DashClaw';
14
+
15
+ /**
16
+ * Fetch the latest published platform version from the npm registry.
17
+ * The `dashclaw` npm package version mirrors the platform version (unified versioning).
18
+ *
19
+ * @param {typeof fetch} fetchImpl - injectable for tests
20
+ * @returns {Promise<string>} semver string e.g. '4.21.0'
21
+ */
22
+ export async function resolveAppVersion(fetchImpl = fetch) {
23
+ const res = await fetchImpl('https://registry.npmjs.org/dashclaw/latest');
24
+ if (!res.ok) {
25
+ throw new Error(`npm registry lookup failed (${res.status}) — check your network and retry.`);
26
+ }
27
+ const { version } = await res.json();
28
+ if (!version) throw new Error('npm registry returned no version for dashclaw.');
29
+ return version;
30
+ }
31
+
32
+ /**
33
+ * Build the GitHub codeload tarball URL for a given version tag.
34
+ *
35
+ * @param {string} version - semver string e.g. '4.21.0'
36
+ * @returns {string}
37
+ */
38
+ export function tarballUrl(version) {
39
+ return `https://codeload.github.com/${REPO}/tar.gz/refs/tags/v${version}`;
40
+ }
41
+
42
+ /**
43
+ * Download and extract the app tarball for `version` into `${baseDir}/app/${version}`.
44
+ * The GitHub tarball wraps everything in a `DashClaw-<version>/` folder —
45
+ * strip 1 level so the app root lands directly in the target dir.
46
+ * Skips cleanly if the target already exists (resume case).
47
+ *
48
+ * @param {object} opts
49
+ * @param {string} opts.version
50
+ * @param {string} opts.baseDir
51
+ * @param {typeof fetch} opts.fetchImpl
52
+ * @param {{ error: (...args: any[]) => void }} opts.logger
53
+ * @returns {Promise<string>} absolute path to the extracted app dir
54
+ */
55
+ export async function downloadAndExtract({ version, baseDir, fetchImpl = fetch, logger = console }) {
56
+ const target = join(baseDir, 'app', version);
57
+ if (existsSync(join(target, 'package.json'))) {
58
+ // Resume-skip: a pre-existing package.json means a prior successful extract.
59
+ logger.error(`[ok] App ${version} already present at ${target}`);
60
+ return target;
61
+ }
62
+ mkdirSync(target, { recursive: true });
63
+ const res = await fetchImpl(tarballUrl(version));
64
+ if (!res.ok || !res.body) {
65
+ rmSync(target, { recursive: true, force: true });
66
+ throw new Error(
67
+ `Download failed (${res.status}) for ${tarballUrl(version)} — does tag v${version} exist?`,
68
+ );
69
+ }
70
+ try {
71
+ await pipeline(Readable.fromWeb(res.body), tar.x({ cwd: target, strip: 1 }));
72
+ if (!existsSync(join(target, 'package.json'))) {
73
+ throw new Error('Extracted tarball did not contain package.json — aborting.');
74
+ }
75
+ } catch (e) {
76
+ rmSync(target, { recursive: true, force: true });
77
+ throw e;
78
+ }
79
+ // Invariant: a target dir with package.json present can only result from a
80
+ // successful prior extract because every failure path above removes the dir.
81
+ return target;
82
+ }
@@ -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
+ }