@dashclaw/cli 0.7.3 → 0.7.6
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/claude/install.js +10 -0
- package/lib/up/db.js +229 -10
- package/lib/up/index.js +15 -4
- package/lib/up/run.js +25 -6
- package/package.json +2 -1
package/lib/claude/install.js
CHANGED
|
@@ -47,6 +47,12 @@ const HOOK_FILES = [
|
|
|
47
47
|
'dashclaw_stop.py',
|
|
48
48
|
'dashclaw_code_session_reporter.py',
|
|
49
49
|
];
|
|
50
|
+
// Shipped when present in the bundle, skipped otherwise — a newer CLI must
|
|
51
|
+
// keep installing against an older hosted bundle. enforcement_liveness_probe
|
|
52
|
+
// (v8.2) is not wired as a hook here (the CLI does not install the
|
|
53
|
+
// SessionStart digest that auto-spawns it), but travels with the hooks so
|
|
54
|
+
// `python .claude/hooks/enforcement_liveness_probe.py` works out of the box.
|
|
55
|
+
const OPTIONAL_HOOK_FILES = ['enforcement_liveness_probe.py'];
|
|
50
56
|
const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
|
|
51
57
|
const HOOKS_BUNDLE_PATH = '/downloads/dashclaw-claude-code-hooks.zip';
|
|
52
58
|
|
|
@@ -136,6 +142,10 @@ function copyHooksFromRepo(hooksSrc, hooksDst) {
|
|
|
136
142
|
if (!existsSync(sp)) throw new Error(`Required hook script missing: ${sp}`);
|
|
137
143
|
copyFileSync(sp, join(hooksDst, name));
|
|
138
144
|
}
|
|
145
|
+
for (const name of OPTIONAL_HOOK_FILES) {
|
|
146
|
+
const sp = join(hooksSrc, name);
|
|
147
|
+
if (existsSync(sp)) copyFileSync(sp, join(hooksDst, name));
|
|
148
|
+
}
|
|
139
149
|
const intelSrc = join(hooksSrc, HOOK_INTEL_DIR);
|
|
140
150
|
if (!existsSync(intelSrc) || !statSync(intelSrc).isDirectory()) {
|
|
141
151
|
throw new Error(`Required intel module missing: ${intelSrc}`);
|
package/lib/up/db.js
CHANGED
|
@@ -17,23 +17,83 @@
|
|
|
17
17
|
// on resume so a working install never silently moves.
|
|
18
18
|
|
|
19
19
|
import { join } from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { tmpdir } from 'node:os';
|
|
21
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
22
|
import { execFileSync, spawnSync } from 'node:child_process';
|
|
22
23
|
|
|
23
24
|
// Keep this in sync with the "embedded-postgres" version in cli/package.json.
|
|
24
25
|
const EMBEDDED_PG_VERSION = 'embedded-postgres@18.4.0-beta.17';
|
|
25
26
|
|
|
27
|
+
// Force a UTF-8 cluster regardless of host locale. Without this, initdb on
|
|
28
|
+
// Windows inherits the OS locale (e.g. English_United States.1252) and
|
|
29
|
+
// creates a WIN1252-encoded database — DashClaw's migration SQL is UTF-8 and
|
|
30
|
+
// statements containing characters with no WIN1252 equivalent hard-fail with
|
|
31
|
+
// 22P05, leaving a partial schema (observed live in Windows Sandbox).
|
|
32
|
+
const INITDB_FLAGS = ['--encoding=UTF8', '--no-locale'];
|
|
33
|
+
|
|
26
34
|
// Windows exit status 0xC0000135 (STATUS_DLL_NOT_FOUND). The embedded
|
|
27
35
|
// Postgres binaries link against the Microsoft Visual C++ runtime, which a
|
|
28
36
|
// fresh Windows install (and Windows Sandbox) does not ship — initdb dies at
|
|
29
37
|
// exec with this code and EMPTY stderr, which reads as pure noise to a
|
|
30
38
|
// first-run user.
|
|
31
39
|
const STATUS_DLL_NOT_FOUND = '3221225781';
|
|
40
|
+
const VC_REDIST_URL = 'https://aka.ms/vs/17/release/vc_redist.x64.exe';
|
|
32
41
|
const VC_REDIST_HINT =
|
|
33
42
|
'Embedded Postgres needs the Microsoft Visual C++ runtime, which this Windows machine does not have. '
|
|
34
|
-
+
|
|
43
|
+
+ `Install it once from ${VC_REDIST_URL} (or \`winget install Microsoft.VCRedist.2015+.x64\`), `
|
|
35
44
|
+ 'then re-run `npx dashclaw up`. Alternatively retry with --db docker or --db url.';
|
|
36
45
|
|
|
46
|
+
// Redistributable installer exit codes that mean the runtime is (or already
|
|
47
|
+
// was) in place: 0 = installed, 1638 = a newer version is already installed,
|
|
48
|
+
// 3010 = installed, reboot pending (the DLLs are on disk and loadable now).
|
|
49
|
+
const VC_REDIST_OK_EXIT_CODES = new Set([0, 1638, 3010]);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Downloads and silently installs the Microsoft Visual C++ redistributable so
|
|
53
|
+
* `npx dashclaw up` stays one command on a fresh Windows machine. Elevation
|
|
54
|
+
* goes through `Start-Process -Verb RunAs`: an already-elevated shell runs it
|
|
55
|
+
* directly, a standard shell surfaces the Windows UAC consent dialog — that
|
|
56
|
+
* dialog IS the user's approval for the system-wide install. Throws (with the
|
|
57
|
+
* manual remediation appended) when the download or install fails; the caller
|
|
58
|
+
* re-checks the DLLs afterwards as the authoritative success signal.
|
|
59
|
+
*/
|
|
60
|
+
export async function installVcRedist({
|
|
61
|
+
logger = console,
|
|
62
|
+
fetchFn = fetch,
|
|
63
|
+
spawn = spawnSync,
|
|
64
|
+
downloadDir = tmpdir(),
|
|
65
|
+
} = {}) {
|
|
66
|
+
logger.error(
|
|
67
|
+
'[..] Embedded Postgres needs the Microsoft Visual C++ runtime — installing it now '
|
|
68
|
+
+ '(one-time, ~25 MB from microsoft.com; Windows may show an admin consent prompt) ...',
|
|
69
|
+
);
|
|
70
|
+
const exePath = join(downloadDir, 'dashclaw-vc_redist.x64.exe');
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await fetchFn(VC_REDIST_URL);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Error(`VC++ runtime download failed (${e.message}). ${VC_REDIST_HINT}`);
|
|
76
|
+
}
|
|
77
|
+
if (!res.ok) {
|
|
78
|
+
throw new Error(`VC++ runtime download failed (HTTP ${res.status}). ${VC_REDIST_HINT}`);
|
|
79
|
+
}
|
|
80
|
+
writeFileSync(exePath, Buffer.from(await res.arrayBuffer()));
|
|
81
|
+
const psPath = exePath.replace(/'/g, "''");
|
|
82
|
+
const script =
|
|
83
|
+
`$p = Start-Process -FilePath '${psPath}' -ArgumentList '/install','/quiet','/norestart' -Verb RunAs -Wait -PassThru; `
|
|
84
|
+
+ 'exit $p.ExitCode';
|
|
85
|
+
const run = spawn('powershell', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', script], { stdio: 'ignore' });
|
|
86
|
+
if (run.error) {
|
|
87
|
+
throw new Error(`VC++ runtime install could not start (${run.error.message}). ${VC_REDIST_HINT}`);
|
|
88
|
+
}
|
|
89
|
+
if (!VC_REDIST_OK_EXIT_CODES.has(run.status)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`VC++ runtime installer exited with code ${run.status} (declined admin prompt, or install failure). ${VC_REDIST_HINT}`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
logger.error('[ok] Microsoft Visual C++ runtime installed.');
|
|
95
|
+
}
|
|
96
|
+
|
|
37
97
|
/**
|
|
38
98
|
* True on Windows machines missing the VC++ runtime DLLs the embedded
|
|
39
99
|
* Postgres binaries need. Checked BEFORE the embedded attempt so the failure
|
|
@@ -51,13 +111,33 @@ export function missingVcRuntime({ platform = process.platform, exists = existsS
|
|
|
51
111
|
* the preflight passed (a different DLL may be the missing one).
|
|
52
112
|
*/
|
|
53
113
|
export function embeddedFailureMessage(err) {
|
|
54
|
-
|
|
55
|
-
|
|
114
|
+
// embedded-postgres rejects with NO value when the postgres process exits
|
|
115
|
+
// before it is ready (its start() does `reject()` bare) — the real reason is
|
|
116
|
+
// in the log lines it already printed. Guard, or this helper itself crashes
|
|
117
|
+
// with "Cannot read properties of undefined" and eats the actual error
|
|
118
|
+
// (observed live in Windows Sandbox).
|
|
119
|
+
const detail = err?.message
|
|
120
|
+
?? 'the Postgres process exited before it was ready — the reason is in the log lines above';
|
|
121
|
+
const base = `Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${detail}`;
|
|
122
|
+
if (String(detail).includes(STATUS_DLL_NOT_FOUND)) {
|
|
56
123
|
return `${base} — this exit code means a required system DLL is missing. ${VC_REDIST_HINT}`;
|
|
57
124
|
}
|
|
58
125
|
return `${base}. Retry with --db docker or --db url.`;
|
|
59
126
|
}
|
|
60
127
|
|
|
128
|
+
/**
|
|
129
|
+
* Extra embedded-postgres options when running as root on POSIX (the fresh
|
|
130
|
+
* root-VPS case, caught live by the first `drill:fresh-linux --as-root` run):
|
|
131
|
+
* postgres refuses to run as root, and embedded-postgres's escape hatch is
|
|
132
|
+
* `createPostgresUser: true` (it provisions a `postgres` system user and runs
|
|
133
|
+
* the cluster as that user). No-op everywhere else — including Windows, where
|
|
134
|
+
* the elevated-token case is handled by the pg_ctl lifecycle instead.
|
|
135
|
+
*/
|
|
136
|
+
export function rootPostgresOptions({ platform = process.platform, getuid = process.getuid } = {}) {
|
|
137
|
+
if (platform === 'win32' || typeof getuid !== 'function') return {};
|
|
138
|
+
return getuid() === 0 ? { createPostgresUser: true } : {};
|
|
139
|
+
}
|
|
140
|
+
|
|
61
141
|
export const DEFAULT_DB_PORT = 5433;
|
|
62
142
|
// Scan window when the preferred port is taken: preferred+1 .. preferred+10.
|
|
63
143
|
const PORT_SCAN_SPAN = 10;
|
|
@@ -91,7 +171,7 @@ export function dockerAvailableSync() {
|
|
|
91
171
|
* @param {Function} [opts.promptFn] - async (message) => string
|
|
92
172
|
* @returns {Promise<'docker'|'embedded'|'url'>}
|
|
93
173
|
*/
|
|
94
|
-
export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn }) {
|
|
174
|
+
export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promptFn, logger = console }) {
|
|
95
175
|
if (flagDb) return flagDb;
|
|
96
176
|
if (yes) return dockerAvailable ? 'docker' : 'embedded';
|
|
97
177
|
|
|
@@ -104,7 +184,12 @@ export async function chooseDbMode({ flagDb, dockerAvailable, yes = false, promp
|
|
|
104
184
|
' 3. I have a postgresql:// URL',
|
|
105
185
|
];
|
|
106
186
|
const def = dockerAvailable ? '1' : '2';
|
|
107
|
-
|
|
187
|
+
// The menu is printed OUTSIDE the readline prompt: on Windows, readline
|
|
188
|
+
// re-renders multi-line prompts on input events, so the whole menu was
|
|
189
|
+
// echoed twice (observed live in Windows Sandbox). Only the one-line
|
|
190
|
+
// "Choice" question goes through promptFn.
|
|
191
|
+
logger.error(lines.join('\n'));
|
|
192
|
+
const answer = (await promptFn(`Choice [${def}]: `)).trim() || def;
|
|
108
193
|
return { 1: 'docker', 2: 'embedded', 3: 'url' }[answer] ?? (dockerAvailable ? 'docker' : 'embedded');
|
|
109
194
|
}
|
|
110
195
|
|
|
@@ -284,7 +369,7 @@ async function dockerProvision({ preferredPort, ops, isFree, logger }) {
|
|
|
284
369
|
export async function provisionDatabase({
|
|
285
370
|
mode, baseDir, promptFn, savedDatabaseUrl,
|
|
286
371
|
dockerOps = realDockerOps, isFree = isPortFree, waitForDbPort = waitForPort, logger = console,
|
|
287
|
-
checkVcRuntime = missingVcRuntime,
|
|
372
|
+
checkVcRuntime = missingVcRuntime, installVc = installVcRedist,
|
|
288
373
|
}) {
|
|
289
374
|
let preferredPort = DEFAULT_DB_PORT;
|
|
290
375
|
if (savedDatabaseUrl) {
|
|
@@ -305,11 +390,24 @@ export async function provisionDatabase({
|
|
|
305
390
|
|
|
306
391
|
// mode === 'embedded'
|
|
307
392
|
if (checkVcRuntime()) {
|
|
308
|
-
|
|
393
|
+
// Fresh Windows machines (and Windows Sandbox) don't ship the VC++
|
|
394
|
+
// runtime the Postgres binaries link against. Install it in-flow instead
|
|
395
|
+
// of failing with homework — `dashclaw up` promises ONE command.
|
|
396
|
+
await installVc({ logger });
|
|
397
|
+
if (checkVcRuntime()) {
|
|
398
|
+
throw new Error(`The VC++ runtime installer finished but the runtime DLLs are still missing. ${VC_REDIST_HINT}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const pgDir = join(baseDir, 'pg');
|
|
402
|
+
if (process.platform === 'win32') {
|
|
403
|
+
// postgres.exe hard-refuses an elevated (admin) token — the norm in
|
|
404
|
+
// Windows Sandbox and admin terminals. pg_ctl creates the restricted
|
|
405
|
+
// token PostgreSQL requires, so on Windows the server lifecycle goes
|
|
406
|
+
// through pg_ctl instead of embedded-postgres spawning postgres directly.
|
|
407
|
+
return provisionEmbeddedWindows({ baseDir, pgDir, preferredPort, isFree, logger });
|
|
309
408
|
}
|
|
310
409
|
const port = await pickDbPort({ preferred: preferredPort, isFree, logger });
|
|
311
410
|
const { default: EmbeddedPostgres } = await import('embedded-postgres');
|
|
312
|
-
const pgDir = join(baseDir, 'pg');
|
|
313
411
|
// Record whether the data dir existed BEFORE this run.
|
|
314
412
|
// Only clean up on failure when WE created it (a pre-existing dir means
|
|
315
413
|
// a working prior install whose data we must not delete).
|
|
@@ -323,9 +421,14 @@ export async function provisionDatabase({
|
|
|
323
421
|
password: localDb.password,
|
|
324
422
|
port,
|
|
325
423
|
persistent: true,
|
|
424
|
+
initdbFlags: INITDB_FLAGS,
|
|
425
|
+
...rootPostgresOptions(),
|
|
326
426
|
});
|
|
327
427
|
try {
|
|
328
|
-
|
|
428
|
+
// initdb refuses a non-empty data dir, so only initialise a cluster that
|
|
429
|
+
// does not exist yet (PG_VERSION marks an initialised cluster) — without
|
|
430
|
+
// this, every resumed `up` with an embedded DB fails at re-init.
|
|
431
|
+
if (!clusterInitialised(pgDir)) await pg.initialise();
|
|
329
432
|
await pg.start();
|
|
330
433
|
} catch (e) {
|
|
331
434
|
if (!dirPreExisted) {
|
|
@@ -338,6 +441,122 @@ export async function provisionDatabase({
|
|
|
338
441
|
return { databaseUrl: localDbUrlFor(port), stop: () => pg.stop() };
|
|
339
442
|
}
|
|
340
443
|
|
|
444
|
+
/** True when pgDir already holds an initialised cluster (initdb completed). */
|
|
445
|
+
export function clusterInitialised(pgDir, exists = existsSync) {
|
|
446
|
+
return exists(join(pgDir, 'PG_VERSION'));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Last lines of the pg_ctl server log, for actionable start-failure errors. */
|
|
450
|
+
async function pgLogTail(logFile, lines = 12) {
|
|
451
|
+
try {
|
|
452
|
+
const { readFileSync } = await import('node:fs');
|
|
453
|
+
const content = readFileSync(logFile, 'utf8').trim().split(/\r?\n/);
|
|
454
|
+
return ` Server log (${logFile}):\n${content.slice(-lines).join('\n')}`;
|
|
455
|
+
} catch {
|
|
456
|
+
return '';
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Windows lifecycle for the embedded cluster, via pg_ctl.
|
|
462
|
+
*
|
|
463
|
+
* Why not embedded-postgres's own start(): it spawns postgres.exe directly,
|
|
464
|
+
* and postgres.exe refuses to run under a token with admin privileges
|
|
465
|
+
* ("Execution of PostgreSQL by a user with administrative permissions is not
|
|
466
|
+
* permitted"). initdb self-restricts its token, pg_ctl restricts the token it
|
|
467
|
+
* starts postgres with — postgres itself does not. Routing start/stop through
|
|
468
|
+
* pg_ctl works from BOTH elevated and normal shells. Consequence: the server
|
|
469
|
+
* is detached from this process, so a previous `up` may have left it running —
|
|
470
|
+
* `pg_ctl status` first, and reuse it on its saved port.
|
|
471
|
+
*
|
|
472
|
+
* All effects are injectable for unit tests.
|
|
473
|
+
*/
|
|
474
|
+
export async function provisionEmbeddedWindows({
|
|
475
|
+
baseDir, pgDir, preferredPort, isFree = isPortFree, logger = console,
|
|
476
|
+
bins, // { pg_ctl } — default: @embedded-postgres/windows-x64
|
|
477
|
+
spawn = spawnSync,
|
|
478
|
+
initCluster, // default: embedded-postgres initialise()
|
|
479
|
+
connectClient, // default: pg Client — async (port) => { query, end }
|
|
480
|
+
}) {
|
|
481
|
+
const localDb = new URL(LOCAL_DB_URL);
|
|
482
|
+
const pgCtl = bins?.pg_ctl ?? (await import('@embedded-postgres/windows-x64')).pg_ctl;
|
|
483
|
+
const logFile = join(baseDir, 'pg.log');
|
|
484
|
+
|
|
485
|
+
const running = spawn(pgCtl, ['-D', pgDir, 'status'], { stdio: 'ignore' }).status === 0;
|
|
486
|
+
let port = preferredPort;
|
|
487
|
+
if (running) {
|
|
488
|
+
// A detached server from a previous `up` is still serving — reuse it on
|
|
489
|
+
// the port it was started with (the saved databaseUrl's port). Probing
|
|
490
|
+
// for a free port here would wrongly route around our own server.
|
|
491
|
+
logger.error(`[ok] Embedded Postgres already running (port ${port}, data in ${pgDir})`);
|
|
492
|
+
} else {
|
|
493
|
+
port = await pickDbPort({ preferred: preferredPort, isFree, logger });
|
|
494
|
+
const dirPreExisted = existsSync(pgDir);
|
|
495
|
+
try {
|
|
496
|
+
if (!clusterInitialised(pgDir)) {
|
|
497
|
+
if (initCluster) {
|
|
498
|
+
await initCluster({ pgDir, user: localDb.username, password: localDb.password });
|
|
499
|
+
} else {
|
|
500
|
+
const { default: EmbeddedPostgres } = await import('embedded-postgres');
|
|
501
|
+
await new EmbeddedPostgres({
|
|
502
|
+
databaseDir: pgDir,
|
|
503
|
+
user: localDb.username,
|
|
504
|
+
password: localDb.password,
|
|
505
|
+
port,
|
|
506
|
+
persistent: true,
|
|
507
|
+
initdbFlags: INITDB_FLAGS,
|
|
508
|
+
}).initialise();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const res = spawn(
|
|
512
|
+
pgCtl,
|
|
513
|
+
['-D', pgDir, '-o', `-p ${port}`, '-l', logFile, '-w', '-t', '60', 'start'],
|
|
514
|
+
{ stdio: 'ignore' },
|
|
515
|
+
);
|
|
516
|
+
if (res.error || res.status !== 0) {
|
|
517
|
+
const reason = res.error ? res.error.message : `pg_ctl start exited ${res.status}.`;
|
|
518
|
+
throw new Error(`${reason}${await pgLogTail(logFile)}`);
|
|
519
|
+
}
|
|
520
|
+
} catch (e) {
|
|
521
|
+
if (!dirPreExisted) {
|
|
522
|
+
rmSync(pgDir, { recursive: true, force: true });
|
|
523
|
+
}
|
|
524
|
+
throw new Error(embeddedFailureMessage(e));
|
|
525
|
+
}
|
|
526
|
+
logger.error(`[ok] Embedded Postgres running (port ${port}, data in ${pgDir})`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
await createDashclawDatabase({ port, localDb, connectClient });
|
|
530
|
+
return {
|
|
531
|
+
databaseUrl: localDbUrlFor(port),
|
|
532
|
+
stop: async () => {
|
|
533
|
+
spawn(pgCtl, ['-D', pgDir, '-m', 'fast', 'stop'], { stdio: 'ignore' });
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** CREATE DATABASE dashclaw, tolerating "already exists" (resume). */
|
|
539
|
+
async function createDashclawDatabase({ port, localDb, connectClient }) {
|
|
540
|
+
const connect = connectClient ?? (async (p) => {
|
|
541
|
+
const { default: pg } = await import('pg');
|
|
542
|
+
const client = new pg.Client({
|
|
543
|
+
host: 'localhost', port: p,
|
|
544
|
+
user: localDb.username, password: localDb.password,
|
|
545
|
+
database: 'postgres',
|
|
546
|
+
});
|
|
547
|
+
await client.connect();
|
|
548
|
+
return client;
|
|
549
|
+
});
|
|
550
|
+
const client = await connect(port);
|
|
551
|
+
try {
|
|
552
|
+
await client.query(`CREATE DATABASE ${localDb.pathname.slice(1)}`);
|
|
553
|
+
} catch (e) {
|
|
554
|
+
if (e.code !== '42P04') throw e; // 42P04 = duplicate_database (resume)
|
|
555
|
+
} finally {
|
|
556
|
+
await client.end();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
341
560
|
/** Polls :port until it accepts TCP connections, or throws on timeout. */
|
|
342
561
|
async function waitForPort(port, timeoutMs) {
|
|
343
562
|
const net = await import('node:net');
|
package/lib/up/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { STEPS, loadInstance, saveInstance, checkpoint } from './instance.js';
|
|
21
21
|
import { resolveAppVersion, downloadAndExtract } from './fetch-app.js';
|
|
22
22
|
import { dockerAvailableSync, chooseDbMode, provisionDatabase } from './db.js';
|
|
23
|
-
import { installDeps, buildApp, startServer, waitForHealth, openBrowser } from './run.js';
|
|
23
|
+
import { installDeps, buildApp, startServer, waitForHealth, openBrowser, winSafeSpawnSync } from './run.js';
|
|
24
24
|
import { installClaude } from '../claude/install.js';
|
|
25
25
|
import { parseUpArgs } from './args.js';
|
|
26
26
|
import { ask } from '../config.js';
|
|
@@ -58,7 +58,7 @@ export function resolveBaseDir(args) {
|
|
|
58
58
|
* @param {object} [opts.logger]
|
|
59
59
|
* @param {Function} [opts.spawn] injectable spawnSync for testing (default: spawnSync)
|
|
60
60
|
*/
|
|
61
|
-
export function runSetupScriptReal({ appDir, databaseUrl, logger = console, spawn: spawnFn =
|
|
61
|
+
export function runSetupScriptReal({ appDir, databaseUrl, logger = console, spawn: spawnFn = winSafeSpawnSync }) {
|
|
62
62
|
logger.error('-> Running setup (migrations + first admin) ...');
|
|
63
63
|
const res = spawnFn(
|
|
64
64
|
'node',
|
|
@@ -71,7 +71,9 @@ export function runSetupScriptReal({ appDir, databaseUrl, logger = console, spaw
|
|
|
71
71
|
],
|
|
72
72
|
// stdin MUST be 'ignore': the default open pipe makes any stray readline
|
|
73
73
|
// prompt in the child hang forever (observed: 12-minute silent hang).
|
|
74
|
-
|
|
74
|
+
// Windows shell handling (npm-style .cmd resolution + DEP0190 avoidance)
|
|
75
|
+
// lives in winSafeSpawnSync.
|
|
76
|
+
{ cwd: appDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] },
|
|
75
77
|
);
|
|
76
78
|
const stdout = res.stdout || '';
|
|
77
79
|
const lines = stdout.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
@@ -178,7 +180,7 @@ export async function runUp({ args, baseDir = join(homedir(), '.dashclaw'), deps
|
|
|
178
180
|
// is both wrong and hangs. Reuse the saved URL when db_ready is already
|
|
179
181
|
// checkpointed and inst.databaseUrl is set.
|
|
180
182
|
const dbMode = inst.dbMode ?? await deps.chooseDbMode({
|
|
181
|
-
flagDb: args.db, dockerAvailable: deps.dockerAvailable, yes: args.yes, promptFn: deps.promptFn,
|
|
183
|
+
flagDb: args.db, dockerAvailable: deps.dockerAvailable, yes: args.yes, promptFn: deps.promptFn, logger,
|
|
182
184
|
});
|
|
183
185
|
const db = (dbMode === 'url' && done('db_ready') && inst.databaseUrl)
|
|
184
186
|
? { databaseUrl: inst.databaseUrl, stop: async () => {} }
|
|
@@ -313,6 +315,15 @@ export async function runDown({
|
|
|
313
315
|
dockerStop('dashclaw-pg');
|
|
314
316
|
logger.log('[ok] Stopped Docker Postgres (container dashclaw-pg).');
|
|
315
317
|
}
|
|
318
|
+
if (inst.dbMode === 'embedded' && process.platform === 'win32') {
|
|
319
|
+
// On Windows the embedded server runs detached via pg_ctl (see db.js) and
|
|
320
|
+
// survives the up process — stop it here, best-effort.
|
|
321
|
+
try {
|
|
322
|
+
const { pg_ctl } = await import('@embedded-postgres/windows-x64');
|
|
323
|
+
const res = spawnSync(pg_ctl, ['-D', join(baseDir, 'pg'), '-m', 'fast', 'stop'], { stdio: 'ignore' });
|
|
324
|
+
if (res.status === 0) logger.log('[ok] Stopped embedded Postgres.');
|
|
325
|
+
} catch { /* binaries not installed — nothing to stop */ }
|
|
326
|
+
}
|
|
316
327
|
}
|
|
317
328
|
|
|
318
329
|
/**
|
package/lib/up/run.js
CHANGED
|
@@ -6,26 +6,45 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
6
6
|
|
|
7
7
|
const shell = process.platform === 'win32';
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
// npm/npx are .cmd files on Windows, which node refuses to spawn without a
|
|
10
|
+
// shell — but shell:true + an args ARRAY is deprecated (DEP0190, and the
|
|
11
|
+
// warning prints mid-flow for first-run users). So on Windows the command is
|
|
12
|
+
// joined into ONE string (quoting any arg with shell-special characters) and
|
|
13
|
+
// handed to the shell whole; elsewhere args pass through verbatim, no shell.
|
|
14
|
+
const quoteArg = (a) => (/^[\w\-.:/@=]+$/.test(String(a)) ? String(a) : `"${a}"`);
|
|
15
|
+
export function winSafeSpawnArgs(cmd, args) {
|
|
16
|
+
return shell
|
|
17
|
+
? { cmd: [cmd, ...args.map(quoteArg)].join(' '), args: [], opts: { shell: true } }
|
|
18
|
+
: { cmd, args, opts: {} };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** spawnSync that takes LOGICAL (cmd, args) and win-joins only at the edge. */
|
|
22
|
+
export function winSafeSpawnSync(cmd, args, opts) {
|
|
23
|
+
const safe = winSafeSpawnArgs(cmd, args);
|
|
24
|
+
return spawnSync(safe.cmd, safe.args, { ...opts, ...safe.opts });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function npm(args, cwd, logger, runner = winSafeSpawnSync) {
|
|
10
28
|
logger.error(`-> npm ${args.join(' ')}`);
|
|
11
|
-
const res = runner('npm', args, { cwd, stdio: ['ignore', 'inherit', 'inherit']
|
|
29
|
+
const res = runner('npm', args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] });
|
|
12
30
|
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
31
|
}
|
|
14
32
|
|
|
15
|
-
export function installDeps(appDir, logger = console, runner =
|
|
33
|
+
export function installDeps(appDir, logger = console, runner = winSafeSpawnSync) {
|
|
16
34
|
try { npm(['ci', '--no-audit', '--no-fund'], appDir, logger, runner); }
|
|
17
35
|
catch { npm(['install', '--no-audit', '--no-fund'], appDir, logger, runner); } // lockfile mismatch fallback
|
|
18
36
|
}
|
|
19
37
|
|
|
20
|
-
export function buildApp(appDir, logger = console, runner =
|
|
38
|
+
export function buildApp(appDir, logger = console, runner = winSafeSpawnSync) {
|
|
21
39
|
npm(['run', 'build'], appDir, logger, runner);
|
|
22
40
|
}
|
|
23
41
|
|
|
24
42
|
/** Start `next start` as a child; .env.local in appDir is loaded by Next itself. */
|
|
25
43
|
export function startServer({ appDir, port, logger = console }) {
|
|
26
44
|
logger.error(`-> Starting server on :${port}`);
|
|
27
|
-
const
|
|
28
|
-
|
|
45
|
+
const safe = winSafeSpawnArgs('npx', ['next', 'start', '-p', String(port)]);
|
|
46
|
+
const child = spawn(safe.cmd, safe.args, {
|
|
47
|
+
cwd: appDir, stdio: ['ignore', 'inherit', 'inherit'], ...safe.opts,
|
|
29
48
|
});
|
|
30
49
|
return child;
|
|
31
50
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dashclaw/cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.6",
|
|
4
4
|
"description": "DashClaw terminal client — approve agent actions and diagnose your instance",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"dashclaw": "^2.2.1",
|
|
29
29
|
"embedded-postgres": "18.4.0-beta.17",
|
|
30
|
+
"pg": "^8.22.0",
|
|
30
31
|
"tar": "^7.4.0"
|
|
31
32
|
},
|
|
32
33
|
"license": "MIT",
|