@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/codex/install.js
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
// `dashclaw install codex` — provisions DashClaw governance into Codex CLI.
|
|
4
4
|
//
|
|
5
5
|
// What it does, idempotently:
|
|
6
|
-
// 1. Copies the Python governance hooks (pretool, posttool, stop
|
|
7
|
-
// vendored `dashclaw_agent_intel` module into
|
|
6
|
+
// 1. Copies the Python governance hooks (pretool, posttool, stop, session
|
|
7
|
+
// digest) and the vendored `dashclaw_agent_intel` module into
|
|
8
|
+
// ~/.codex/hooks/dashclaw/.
|
|
8
9
|
// 2. Merges a managed block into ~/.codex/config.toml that registers:
|
|
9
10
|
// - the DashClaw MCP server (stdio)
|
|
10
|
-
// - PreToolUse / PostToolUse / Stop hooks pointing at
|
|
11
|
+
// - PreToolUse / PostToolUse / Stop / SessionStart hooks pointing at
|
|
12
|
+
// the copied scripts
|
|
11
13
|
// - approval_policy = "on-request" so Codex surfaces require_approval
|
|
12
14
|
// decisions from DashClaw guard
|
|
13
15
|
// 3. Drops a managed block into <project>/AGENTS.md (or creates the file)
|
|
@@ -57,6 +59,12 @@ const HOOK_FILES = [
|
|
|
57
59
|
'dashclaw_pretool.py',
|
|
58
60
|
'dashclaw_posttool.py',
|
|
59
61
|
'dashclaw_stop.py',
|
|
62
|
+
// dashclaw_stop.py imports this for Code Sessions ingest; without it the
|
|
63
|
+
// import fails inside a try/except and ingest silently no-ops (v2.7 fix).
|
|
64
|
+
'dashclaw_code_session_reporter.py',
|
|
65
|
+
// SessionStart digest (v3.7 item 6) — wired once codex-cli 0.139.0's
|
|
66
|
+
// SessionStart hook event was confirmed to fire (see PLUGIN_PARITY.md).
|
|
67
|
+
'dashclaw_session_digest.py',
|
|
60
68
|
];
|
|
61
69
|
const HOOK_INTEL_DIR = 'dashclaw_agent_intel';
|
|
62
70
|
|
|
@@ -184,6 +192,7 @@ export function buildConfigTomlBlock({
|
|
|
184
192
|
const pre = join(hooksDir, 'dashclaw_pretool.py');
|
|
185
193
|
const post = join(hooksDir, 'dashclaw_posttool.py');
|
|
186
194
|
const stop = join(hooksDir, 'dashclaw_stop.py');
|
|
195
|
+
const sessionStart = join(hooksDir, 'dashclaw_session_digest.py');
|
|
187
196
|
|
|
188
197
|
const lines = [
|
|
189
198
|
MANAGED_START,
|
|
@@ -215,19 +224,31 @@ export function buildConfigTomlBlock({
|
|
|
215
224
|
'matcher = "Bash|Edit|Write|MultiEdit"',
|
|
216
225
|
'[[hooks.PreToolUse.hooks]]',
|
|
217
226
|
'type = "command"',
|
|
218
|
-
|
|
227
|
+
// --agent-id on the hook command line mirrors the MCP server args above:
|
|
228
|
+
// the hooks' argv identity beats the machine-ambient DASHCLAW_AGENT_ID
|
|
229
|
+
// env var, so Codex tool calls are never mis-attributed to another
|
|
230
|
+
// harness (roadmap v2.2).
|
|
231
|
+
`command = ${tomlString(`${py} ${pre} --agent-id codex`)}`,
|
|
219
232
|
'timeoutSec = 3600',
|
|
220
233
|
'',
|
|
221
234
|
'[[hooks.PostToolUse]]',
|
|
222
235
|
'matcher = "Bash|Edit|Write|MultiEdit"',
|
|
223
236
|
'[[hooks.PostToolUse.hooks]]',
|
|
224
237
|
'type = "command"',
|
|
225
|
-
`command = ${tomlString(`${py} ${post}`)}`,
|
|
238
|
+
`command = ${tomlString(`${py} ${post} --agent-id codex`)}`,
|
|
226
239
|
'',
|
|
227
240
|
'[[hooks.Stop]]',
|
|
228
241
|
'[[hooks.Stop.hooks]]',
|
|
229
242
|
'type = "command"',
|
|
230
|
-
`command = ${tomlString(`${py} ${stop}`)}`,
|
|
243
|
+
`command = ${tomlString(`${py} ${stop} --agent-id codex`)}`,
|
|
244
|
+
'',
|
|
245
|
+
// Codex CLI 0.139.0's hook-event enum contains SessionStart alongside
|
|
246
|
+
// Pre/Post/Stop; wired once that lifecycle was verified to fire
|
|
247
|
+
// (roadmap v3.7 item 6 — see PLUGIN_PARITY.md).
|
|
248
|
+
'[[hooks.SessionStart]]',
|
|
249
|
+
'[[hooks.SessionStart.hooks]]',
|
|
250
|
+
'type = "command"',
|
|
251
|
+
`command = ${tomlString(`${py} ${sessionStart} --agent-id codex`)}`,
|
|
231
252
|
MANAGED_END,
|
|
232
253
|
);
|
|
233
254
|
|
package/lib/config.js
CHANGED
|
@@ -37,17 +37,29 @@ export function clearConfigFile() {
|
|
|
37
37
|
|
|
38
38
|
export function ask(question) {
|
|
39
39
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
40
|
-
return new Promise((res) => {
|
|
40
|
+
return new Promise((res, rej) => {
|
|
41
|
+
let answered = false;
|
|
41
42
|
rl.question(question, (answer) => {
|
|
43
|
+
answered = true;
|
|
42
44
|
rl.close();
|
|
43
45
|
res(answer.trim());
|
|
44
46
|
});
|
|
47
|
+
// stdin ending (piped input exhausted, Ctrl+D) used to leave the promise
|
|
48
|
+
// pending forever — node then drained the event loop and exited 0 as if
|
|
49
|
+
// the install had succeeded (v5.4 outsider run). Fail loudly instead.
|
|
50
|
+
rl.on('close', () => {
|
|
51
|
+
if (!answered) rej(new Error('stdin closed before the prompt was answered.'));
|
|
52
|
+
});
|
|
45
53
|
});
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export function askSecret(question) {
|
|
49
|
-
return new Promise((res) => {
|
|
57
|
+
return new Promise((res, rej) => {
|
|
50
58
|
const stdin = process.stdin;
|
|
59
|
+
if (stdin.readableEnded || stdin.destroyed) {
|
|
60
|
+
rej(new Error('stdin closed before the prompt was answered.'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
51
63
|
process.stdout.write(question);
|
|
52
64
|
const wasRaw = stdin.isRaw;
|
|
53
65
|
if (stdin.setRawMode) stdin.setRawMode(true);
|
|
@@ -55,6 +67,13 @@ export function askSecret(question) {
|
|
|
55
67
|
stdin.setEncoding('utf8');
|
|
56
68
|
|
|
57
69
|
let input = '';
|
|
70
|
+
// Same silent-exit-0 hazard as ask(): if stdin ends before a newline,
|
|
71
|
+
// reject instead of leaving the promise pending while node exits clean.
|
|
72
|
+
const onEnd = () => {
|
|
73
|
+
stdin.removeListener('data', onData);
|
|
74
|
+
rej(new Error('stdin closed before the prompt was answered.'));
|
|
75
|
+
};
|
|
76
|
+
stdin.once('end', onEnd);
|
|
58
77
|
const onData = (char) => {
|
|
59
78
|
const str = String(char);
|
|
60
79
|
for (const c of str) {
|
|
@@ -62,6 +81,7 @@ export function askSecret(question) {
|
|
|
62
81
|
if (stdin.setRawMode) stdin.setRawMode(wasRaw);
|
|
63
82
|
stdin.pause();
|
|
64
83
|
stdin.removeListener('data', onData);
|
|
84
|
+
stdin.removeListener('end', onEnd);
|
|
65
85
|
process.stdout.write('\n');
|
|
66
86
|
res(input.trim());
|
|
67
87
|
return;
|
|
@@ -87,6 +107,31 @@ export function askSecret(question) {
|
|
|
87
107
|
});
|
|
88
108
|
}
|
|
89
109
|
|
|
110
|
+
// The CLI sends x-api-key over whatever scheme baseUrl has. Plaintext http to
|
|
111
|
+
// a non-local host exposes the key to the network path — warn once per
|
|
112
|
+
// process (don't refuse: LAN self-hosting over http is a supported setup).
|
|
113
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]', '::1', '0.0.0.0']);
|
|
114
|
+
let warnedInsecureUrl = false;
|
|
115
|
+
|
|
116
|
+
export function __resetInsecureUrlWarning() {
|
|
117
|
+
warnedInsecureUrl = false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function warnIfInsecureBaseUrl(baseUrl) {
|
|
121
|
+
if (warnedInsecureUrl) return;
|
|
122
|
+
try {
|
|
123
|
+
const url = new URL(baseUrl);
|
|
124
|
+
if (url.protocol === 'http:' && !LOCAL_HOSTNAMES.has(url.hostname)) {
|
|
125
|
+
warnedInsecureUrl = true;
|
|
126
|
+
console.error(yellow(
|
|
127
|
+
`Warning: DashClaw base URL ${url.origin} uses plaintext http — your API key is sent unencrypted. Use https for non-local instances.`
|
|
128
|
+
));
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// Unparseable URL — the request itself will surface the real error.
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
90
135
|
/**
|
|
91
136
|
* Resolve config from env, then file, then interactive prompt.
|
|
92
137
|
* @returns {Promise<{ baseUrl, apiKey, agentId, source } | null>}
|
|
@@ -112,6 +157,7 @@ export async function resolveConfig({ env = process.env, interactive = true } =
|
|
|
112
157
|
}
|
|
113
158
|
|
|
114
159
|
if (baseUrl && apiKey) {
|
|
160
|
+
warnIfInsecureBaseUrl(baseUrl);
|
|
115
161
|
return { baseUrl, apiKey, agentId: agentId || 'cli-operator', source };
|
|
116
162
|
}
|
|
117
163
|
|
|
@@ -156,5 +202,6 @@ export async function resolveConfig({ env = process.env, interactive = true } =
|
|
|
156
202
|
}
|
|
157
203
|
console.log();
|
|
158
204
|
|
|
205
|
+
warnIfInsecureBaseUrl(baseUrl);
|
|
159
206
|
return { baseUrl, apiKey, agentId, source: 'prompted' };
|
|
160
207
|
}
|
package/lib/up/args.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const DB_MODES = ['docker', 'embedded', 'url'];
|
|
2
|
+
|
|
3
|
+
export function parseUpArgs(argv) {
|
|
4
|
+
const out = { update: false, yes: false, noBrowser: false, db: null, dir: null, port: null, sourceDir: null };
|
|
5
|
+
for (let i = 0; i < argv.length; i++) {
|
|
6
|
+
const a = argv[i];
|
|
7
|
+
if (a === '--update') out.update = true;
|
|
8
|
+
else if (a === '--yes') out.yes = true;
|
|
9
|
+
else if (a === '--no-browser') out.noBrowser = true;
|
|
10
|
+
else if (a === '--db') {
|
|
11
|
+
const v = argv[++i];
|
|
12
|
+
if (!DB_MODES.includes(v)) throw new Error(`--db must be one of: ${DB_MODES.join(', ')}`);
|
|
13
|
+
out.db = v;
|
|
14
|
+
} else if (a === '--dir') out.dir = argv[++i] ?? null;
|
|
15
|
+
else if (a === '--source-dir') out.sourceDir = argv[++i] ?? null;
|
|
16
|
+
else if (a === '--port') {
|
|
17
|
+
const v = Number(argv[++i]);
|
|
18
|
+
if (!Number.isInteger(v) || v < 1 || v > 65535) throw new Error('--port must be an integer 1-65535');
|
|
19
|
+
out.port = v;
|
|
20
|
+
} else if (a.startsWith('--')) {
|
|
21
|
+
throw new Error(`Unknown flag: ${a}. Run 'dashclaw help' for usage.`);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|
package/lib/up/db.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// cli/lib/up/db.js
|
|
2
|
+
//
|
|
3
|
+
// Database mode selection and provisioning for `dashclaw up`.
|
|
4
|
+
//
|
|
5
|
+
// Three modes:
|
|
6
|
+
// docker — starts/reuses a `dashclaw-pg` Docker container (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
|
+
// Derive credentials from LOCAL_DB_URL so the local dev creds live in
|
|
124
|
+
// exactly one place rather than being duplicated as bare literals here.
|
|
125
|
+
const localDb = new URL(LOCAL_DB_URL);
|
|
126
|
+
const pg = new EmbeddedPostgres({
|
|
127
|
+
databaseDir: pgDir,
|
|
128
|
+
user: localDb.username,
|
|
129
|
+
password: localDb.password,
|
|
130
|
+
port: Number(localDb.port),
|
|
131
|
+
persistent: true,
|
|
132
|
+
});
|
|
133
|
+
try {
|
|
134
|
+
await pg.initialise();
|
|
135
|
+
await pg.start();
|
|
136
|
+
} catch (e) {
|
|
137
|
+
if (!dirPreExisted) {
|
|
138
|
+
rmSync(pgDir, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
throw new Error(
|
|
141
|
+
`Embedded Postgres (${EMBEDDED_PG_VERSION}) failed: ${e.message}. Retry with --db docker or --db url.`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
try { await pg.createDatabase('dashclaw'); } catch { /* already exists on resume — fine */ }
|
|
145
|
+
logger.error('[ok] Embedded Postgres running (port 5433, data in ~/.dashclaw/pg)');
|
|
146
|
+
return { databaseUrl: LOCAL_DB_URL, stop: () => pg.stop() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Polls :port until it accepts TCP connections, or throws on timeout. */
|
|
150
|
+
async function waitForPort(port, timeoutMs) {
|
|
151
|
+
const net = await import('node:net');
|
|
152
|
+
const deadline = Date.now() + timeoutMs;
|
|
153
|
+
while (Date.now() < deadline) {
|
|
154
|
+
const ok = await new Promise((resolve) => {
|
|
155
|
+
const s = net.connect(port, '127.0.0.1');
|
|
156
|
+
s.once('connect', () => { s.destroy(); resolve(true); });
|
|
157
|
+
s.once('error', () => resolve(false));
|
|
158
|
+
});
|
|
159
|
+
if (ok) return;
|
|
160
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`Postgres did not accept connections on :${port} within ${timeoutMs / 1000}s.`);
|
|
163
|
+
}
|
|
@@ -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
|
+
}
|