@craftspace/cli 0.1.0 → 0.2.1
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/dist/index.js +2338 -143
- package/dist/machine.d.ts +15 -1
- package/dist/machine.js +103 -14
- package/dist/probe.js +13 -13
- package/dist/run.js +26 -4
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +49 -5
- package/package.json +2 -1
package/dist/machine.d.ts
CHANGED
|
@@ -6,10 +6,15 @@ export declare const machine: {
|
|
|
6
6
|
url: string;
|
|
7
7
|
install: boolean;
|
|
8
8
|
}): Promise<Machine>;
|
|
9
|
+
signIn({ url, why, install }: {
|
|
10
|
+
url?: string;
|
|
11
|
+
why: string;
|
|
12
|
+
install?: boolean;
|
|
13
|
+
}): Promise<SignedIn>;
|
|
9
14
|
status(): Promise<{
|
|
10
15
|
machine: Machine;
|
|
11
16
|
url: string;
|
|
12
|
-
}
|
|
17
|
+
}>;
|
|
13
18
|
beatOnce(): Promise<boolean>;
|
|
14
19
|
beatForever(): Promise<void>;
|
|
15
20
|
run({ argv, on }: {
|
|
@@ -18,4 +23,13 @@ export declare const machine: {
|
|
|
18
23
|
}): Promise<number>;
|
|
19
24
|
logout(): Promise<void>;
|
|
20
25
|
};
|
|
26
|
+
export declare function tokenIn(line: string): string | undefined;
|
|
21
27
|
export declare function nextDelayMs(failures: number, busy?: boolean): number;
|
|
28
|
+
interface Auth {
|
|
29
|
+
url: string;
|
|
30
|
+
token: string;
|
|
31
|
+
}
|
|
32
|
+
interface SignedIn extends Auth {
|
|
33
|
+
machine: Machine;
|
|
34
|
+
}
|
|
35
|
+
export {};
|
package/dist/machine.js
CHANGED
|
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, MachineRunSchema, MachineRunsResponseSchema, MachineSchema, MachineSessionSchema, MachinesResponseSchema, } from '@craftspace/shared';
|
|
7
|
+
import { confirm, input, select } from '@inquirer/prompts';
|
|
7
8
|
import { z } from 'zod';
|
|
8
9
|
import { runner } from './run.js';
|
|
9
10
|
import { setup } from './setup.js';
|
|
@@ -40,15 +41,32 @@ export const machine = {
|
|
|
40
41
|
};
|
|
41
42
|
return answer.machine;
|
|
42
43
|
},
|
|
44
|
+
async signIn({ url, why, install = false }) {
|
|
45
|
+
const where = url ?? (await readConfig())?.url ?? DEFAULT_URL;
|
|
46
|
+
if (!process.stdin.isTTY)
|
|
47
|
+
throw new Error(`${why} Run: craftspace login --token <token>`);
|
|
48
|
+
const page = `${where}/workstations`;
|
|
49
|
+
process.stdout.write(`\n${why}\n\n`);
|
|
50
|
+
if (await confirm({ message: `Open ${page} for a key?`, default: true })) {
|
|
51
|
+
await openInBrowser(page);
|
|
52
|
+
process.stdout.write(' Click "Add a workstation" there, then copy the line it gives you.\n');
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
process.stdout.write(` Get one at ${page} under "Add a workstation".\n`);
|
|
56
|
+
}
|
|
57
|
+
const token = tokenIn(await input({
|
|
58
|
+
message: 'Paste it here',
|
|
59
|
+
validate: (line) => tokenIn(line) !== undefined || 'No cst_… token in that. Paste the whole line if it is easier.',
|
|
60
|
+
}));
|
|
61
|
+
if (token === undefined)
|
|
62
|
+
throw new Error('That line carries no key.');
|
|
63
|
+
const signed = await machine.login({ token, url: where, install });
|
|
64
|
+
process.stdout.write(`\nSigned in as ${signed.ownerName}. This machine is ${signed.name}.\n`);
|
|
65
|
+
return { url: where, token, machine: signed };
|
|
66
|
+
},
|
|
43
67
|
async status() {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
return null;
|
|
47
|
-
const token = await readToken();
|
|
48
|
-
if (!token)
|
|
49
|
-
return null;
|
|
50
|
-
const found = await call({ url: config.url, token, method: 'GET', path: '/api/machines/me', schema: MachineSchema });
|
|
51
|
-
return { machine: found, url: config.url };
|
|
68
|
+
const found = await withAuth(await signedInOrAsk(), (auth) => call({ ...auth, method: 'GET', path: '/api/machines/me', schema: MachineSchema }));
|
|
69
|
+
return { machine: found.value, url: found.auth.url };
|
|
52
70
|
},
|
|
53
71
|
async beatOnce() {
|
|
54
72
|
const { url, token } = await requireSignedIn();
|
|
@@ -129,8 +147,9 @@ async function runHere(argv) {
|
|
|
129
147
|
}
|
|
130
148
|
}
|
|
131
149
|
async function runThere({ argv, on }) {
|
|
132
|
-
const
|
|
133
|
-
const target = await machineNamed({
|
|
150
|
+
const first = await signedInOrAsk();
|
|
151
|
+
const { auth, value: target } = await withAuth(first, (live) => machineNamed({ ...live, name: on }));
|
|
152
|
+
const { url, token } = auth;
|
|
134
153
|
let run = await call({
|
|
135
154
|
url,
|
|
136
155
|
token,
|
|
@@ -186,10 +205,21 @@ async function readRun({ url, token, machineId, runId, }) {
|
|
|
186
205
|
async function machineNamed({ url, token, name, }) {
|
|
187
206
|
const answer = await call({ url, token, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema });
|
|
188
207
|
const found = answer.machines.find((candidate) => candidate.name === name || candidate.id === name);
|
|
189
|
-
if (found
|
|
208
|
+
if (found !== undefined)
|
|
209
|
+
return found;
|
|
210
|
+
if (answer.machines.length === 0)
|
|
211
|
+
throw new Error('This team has no workstations yet. Add one at /workstations.');
|
|
212
|
+
if (!process.stdin.isTTY) {
|
|
190
213
|
throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
|
|
191
214
|
}
|
|
192
|
-
|
|
215
|
+
process.stdout.write(`\nNo workstation called ${name}.\n\n`);
|
|
216
|
+
return select({
|
|
217
|
+
message: 'Run it on',
|
|
218
|
+
choices: answer.machines.map((candidate) => ({
|
|
219
|
+
name: `${candidate.name.padEnd(20)} ${candidate.state}`,
|
|
220
|
+
value: candidate,
|
|
221
|
+
})),
|
|
222
|
+
});
|
|
193
223
|
}
|
|
194
224
|
function readStdinLine() {
|
|
195
225
|
return new Promise((resolve) => {
|
|
@@ -244,10 +274,46 @@ async function call({ url, token, method, path: route, body, schema, }) {
|
|
|
244
274
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
245
275
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
246
276
|
});
|
|
277
|
+
if (response.status === 401)
|
|
278
|
+
throw new Unauthorized();
|
|
247
279
|
if (!response.ok)
|
|
248
280
|
throw new Error(`${route} answered ${response.status}`);
|
|
249
281
|
return schema.parse(await response.json());
|
|
250
282
|
}
|
|
283
|
+
class Unauthorized extends Error {
|
|
284
|
+
constructor() {
|
|
285
|
+
super('This machine no longer has a key Craftspace accepts.');
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function signedInOrAsk() {
|
|
289
|
+
const config = await readConfig();
|
|
290
|
+
const token = await readToken();
|
|
291
|
+
if (config && token)
|
|
292
|
+
return { url: config.url, token };
|
|
293
|
+
return machine.signIn({ why: 'This machine is not signed in to Craftspace yet.' });
|
|
294
|
+
}
|
|
295
|
+
async function withAuth(auth, work) {
|
|
296
|
+
try {
|
|
297
|
+
return { auth, value: await work(auth) };
|
|
298
|
+
}
|
|
299
|
+
catch (error) {
|
|
300
|
+
if (!(error instanceof Unauthorized))
|
|
301
|
+
throw error;
|
|
302
|
+
const next = await machine.signIn({
|
|
303
|
+
url: auth.url,
|
|
304
|
+
why: 'Craftspace does not know this machine any more. Its key was revoked, which is what forgetting the workstation, or signing the same box in somewhere else, does.',
|
|
305
|
+
});
|
|
306
|
+
process.stdout.write('\nPicking up where you left off.\n\n');
|
|
307
|
+
return { auth: next, value: await work(next) };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
async function openInBrowser(url) {
|
|
311
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
312
|
+
await run(opener, [url]).catch(() => undefined);
|
|
313
|
+
}
|
|
314
|
+
export function tokenIn(line) {
|
|
315
|
+
return /cst_[A-Za-z0-9_-]+/.exec(line)?.[0];
|
|
316
|
+
}
|
|
251
317
|
async function ensureKey() {
|
|
252
318
|
const priv = path.join(machine.home(), 'id_ed25519');
|
|
253
319
|
const pub = `${priv}.pub`;
|
|
@@ -282,8 +348,6 @@ async function readSessions() {
|
|
|
282
348
|
}
|
|
283
349
|
return sessions.slice(0, MAX_REPORTED_SESSIONS);
|
|
284
350
|
}
|
|
285
|
-
// Only the block between our markers is ours. Everything a person put in this file by hand is left exactly
|
|
286
|
-
// where it was, because the fastest way to lock a team out of its own box is to rewrite this file wholesale.
|
|
287
351
|
async function writeAuthorizedKeys(keys) {
|
|
288
352
|
const target = path.join(os.homedir(), '.ssh', 'authorized_keys');
|
|
289
353
|
const current = await readFile(target, 'utf8').catch(() => '');
|
|
@@ -321,6 +385,30 @@ async function writeAgentConfigs({ url, token, writes, }) {
|
|
|
321
385
|
owned.push(target);
|
|
322
386
|
}
|
|
323
387
|
await writeFile(ownedPath(), JSON.stringify({ paths: owned }, null, 2));
|
|
388
|
+
await acceptClaudeGates(serversIn(planned));
|
|
389
|
+
}
|
|
390
|
+
function serversIn(writes) {
|
|
391
|
+
return writes.flatMap((write) => isPlainObject(write.contents.mcpServers) ? Object.keys(write.contents.mcpServers) : []);
|
|
392
|
+
}
|
|
393
|
+
async function acceptClaudeGates(servers) {
|
|
394
|
+
const target = path.join(os.homedir(), '.claude.json');
|
|
395
|
+
const raw = await readFile(target, 'utf8').catch(() => null);
|
|
396
|
+
if (raw === null)
|
|
397
|
+
return;
|
|
398
|
+
const parsed = JSON.parse(raw);
|
|
399
|
+
const projects = isPlainObject(parsed.projects) ? parsed.projects : {};
|
|
400
|
+
const found = projects[os.homedir()];
|
|
401
|
+
const here = isPlainObject(found) ? { ...found } : {};
|
|
402
|
+
const enabled = new Set([...(Array.isArray(here.enabledMcpjsonServers) ? here.enabledMcpjsonServers : []), ...servers]);
|
|
403
|
+
if (here.hasTrustDialogAccepted === true && enabled.size === asArray(here.enabledMcpjsonServers).length)
|
|
404
|
+
return;
|
|
405
|
+
here.hasTrustDialogAccepted = true;
|
|
406
|
+
here.enabledMcpjsonServers = [...enabled];
|
|
407
|
+
const next = { ...parsed, projects: { ...projects, [os.homedir()]: here } };
|
|
408
|
+
await writeFile(target, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
409
|
+
}
|
|
410
|
+
function asArray(value) {
|
|
411
|
+
return Array.isArray(value) ? value : [];
|
|
324
412
|
}
|
|
325
413
|
function mergeInto(current, incoming) {
|
|
326
414
|
const merged = { ...current };
|
|
@@ -466,6 +554,7 @@ const MAX_BACKOFF_MS = 120_000;
|
|
|
466
554
|
const KEYS_BEGIN = '# craftspace begin';
|
|
467
555
|
const KEYS_END = '# craftspace end';
|
|
468
556
|
const POLL_INTERVAL_MS = 1_000;
|
|
557
|
+
const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
|
|
469
558
|
const PROBE_EVERY_MS = 300_000;
|
|
470
559
|
let probed = null;
|
|
471
560
|
const reports = new Map();
|
package/dist/probe.js
CHANGED
|
@@ -33,24 +33,24 @@ async function brainWired(mcpPath) {
|
|
|
33
33
|
}
|
|
34
34
|
async function version({ id, command }) {
|
|
35
35
|
const found = await firstLine(command, ['--version']);
|
|
36
|
-
if (
|
|
37
|
-
return { id, ok: true, detail: found };
|
|
38
|
-
return { id, ok: false, detail: found === 'absent' ? 'not installed' : 'installed, but it will not report a version' };
|
|
36
|
+
if (found.ran)
|
|
37
|
+
return { id, ok: true, detail: found.line };
|
|
38
|
+
return { id, ok: false, detail: found.why === 'absent' ? 'not installed' : 'installed, but it will not report a version' };
|
|
39
39
|
}
|
|
40
40
|
async function gitAndGithub() {
|
|
41
41
|
const git = await firstLine('git', ['--version']);
|
|
42
|
-
if (
|
|
42
|
+
if (!git.ran)
|
|
43
43
|
return { id: 'git', ok: false, detail: 'git is not installed' };
|
|
44
44
|
const gh = await firstLine('gh', ['--version']);
|
|
45
|
-
if (
|
|
46
|
-
return { id: 'git', ok: false, detail: `${git} · the GitHub CLI is not installed yet` };
|
|
45
|
+
if (!gh.ran)
|
|
46
|
+
return { id: 'git', ok: false, detail: `${git.line} · the GitHub CLI is not installed yet` };
|
|
47
47
|
const account = await githubAccount();
|
|
48
48
|
return {
|
|
49
49
|
id: 'git',
|
|
50
50
|
ok: account !== null,
|
|
51
51
|
detail: account === null
|
|
52
|
-
? `${git} · ${gh}, run gh auth login on this machine`
|
|
53
|
-
: `${git} · ${gh}, signed in as ${account}`,
|
|
52
|
+
? `${git.line} · ${gh.line}, run gh auth login on this machine`
|
|
53
|
+
: `${git.line} · ${gh.line}, signed in as ${account}`,
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
56
|
async function githubAccount() {
|
|
@@ -63,8 +63,8 @@ export function githubUser(hostsYml) {
|
|
|
63
63
|
async function chromeInstalled() {
|
|
64
64
|
for (const command of [...MAC_CHROME, ...LINUX_CHROME]) {
|
|
65
65
|
const found = await firstLine(command, ['--version']);
|
|
66
|
-
if (
|
|
67
|
-
return { id: 'chrome', ok: true, detail: found };
|
|
66
|
+
if (found.ran)
|
|
67
|
+
return { id: 'chrome', ok: true, detail: found.line };
|
|
68
68
|
}
|
|
69
69
|
return { id: 'chrome', ok: false, detail: 'not installed yet, so browser runs will fail' };
|
|
70
70
|
}
|
|
@@ -85,13 +85,13 @@ async function firstLine(command, args) {
|
|
|
85
85
|
if (command.includes('/')) {
|
|
86
86
|
const reachable = await access(command).then(() => true, () => false);
|
|
87
87
|
if (!reachable)
|
|
88
|
-
return 'absent';
|
|
88
|
+
return { ran: false, why: 'absent' };
|
|
89
89
|
}
|
|
90
90
|
const answer = await run(command, args, { timeout: PROBE_TIMEOUT_MS }).catch((thrown) => thrown);
|
|
91
91
|
if (answer instanceof Error)
|
|
92
|
-
return answer.code === 'ENOENT' ? 'absent' : 'broken';
|
|
92
|
+
return { ran: false, why: answer.code === 'ENOENT' ? 'absent' : 'broken' };
|
|
93
93
|
const line = answer.stdout.split('\n')[0]?.trim() ?? '';
|
|
94
|
-
return line === '' ? 'broken' : line.slice(0, 200);
|
|
94
|
+
return line === '' ? { ran: false, why: 'broken' } : { ran: true, line: line.slice(0, 200) };
|
|
95
95
|
}
|
|
96
96
|
function short(target) {
|
|
97
97
|
return target.startsWith(os.homedir()) ? `~${target.slice(os.homedir().length)}` : target;
|
package/dist/run.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
2
3
|
import { MACHINE_RUN_OUTPUT_MAX, spreadIfDefined } from '@craftspace/shared';
|
|
3
4
|
export const runner = {
|
|
4
5
|
attended(argv) {
|
|
@@ -10,6 +11,7 @@ export const runner = {
|
|
|
10
11
|
},
|
|
11
12
|
async unattended({ work, onProgress, }) {
|
|
12
13
|
const argv = headlessArgv(work);
|
|
14
|
+
const raw = argv[0] === 'script';
|
|
13
15
|
let output = '';
|
|
14
16
|
let agentSessionId = work.agentSessionId ?? undefined;
|
|
15
17
|
let lastText = '';
|
|
@@ -33,10 +35,24 @@ export const runner = {
|
|
|
33
35
|
}
|
|
34
36
|
};
|
|
35
37
|
const code = await new Promise((resolve) => {
|
|
36
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
38
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
39
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
+
cwd: os.homedir(),
|
|
41
|
+
env: {
|
|
42
|
+
...process.env,
|
|
43
|
+
TERM: process.env.TERM ?? 'xterm-256color',
|
|
44
|
+
COLUMNS: process.env.COLUMNS ?? '120',
|
|
45
|
+
LINES: process.env.LINES ?? '40',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
append('');
|
|
37
49
|
child.stdout.setEncoding('utf8');
|
|
38
50
|
child.stderr.setEncoding('utf8');
|
|
39
51
|
child.stdout.on('data', (chunk) => {
|
|
52
|
+
if (raw) {
|
|
53
|
+
append(chunk);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
40
56
|
pending += chunk;
|
|
41
57
|
const lines = pending.split('\n');
|
|
42
58
|
pending = lines.pop() ?? '';
|
|
@@ -74,10 +90,8 @@ export function headlessArgv(work) {
|
|
|
74
90
|
const argv = work.argv;
|
|
75
91
|
if (!isAgent(argv[0]))
|
|
76
92
|
return argv;
|
|
77
|
-
// A Remote Control session is interactive and long-lived, and its whole point is that a phone drives it.
|
|
78
|
-
// Wrapping it in -p would turn it into a one-shot print run with nothing to attach to.
|
|
79
93
|
if (argv.includes('--remote-control'))
|
|
80
|
-
return argv;
|
|
94
|
+
return underTty(argv);
|
|
81
95
|
const streaming = ['--output-format', 'stream-json', '--verbose'];
|
|
82
96
|
if (work.answer !== null && work.agentSessionId !== null) {
|
|
83
97
|
return [argv[0], '--resume', work.agentSessionId, '-p', work.answer, ...streaming];
|
|
@@ -86,6 +100,14 @@ export function headlessArgv(work) {
|
|
|
86
100
|
return [...argv, ...streaming];
|
|
87
101
|
return [argv[0], '-p', argv.slice(1).join(' '), ...streaming];
|
|
88
102
|
}
|
|
103
|
+
function underTty(argv) {
|
|
104
|
+
const line = `stty rows ${PTY_ROWS} cols ${PTY_COLS} 2>/dev/null; exec ${argv.join(' ')}`;
|
|
105
|
+
return process.platform === 'linux'
|
|
106
|
+
? ['script', '-qec', line, '/dev/null']
|
|
107
|
+
: ['script', '-q', '/dev/null', 'sh', '-c', line];
|
|
108
|
+
}
|
|
109
|
+
const PTY_ROWS = 40;
|
|
110
|
+
const PTY_COLS = 120;
|
|
89
111
|
function isAgent(command) {
|
|
90
112
|
return AGENTS.has((command ?? '').split('/').pop() ?? '');
|
|
91
113
|
}
|
package/dist/setup.d.ts
CHANGED
package/dist/setup.js
CHANGED
|
@@ -1,31 +1,72 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
2
4
|
import { promisify } from 'node:util';
|
|
3
5
|
import { probe } from './probe.js';
|
|
4
6
|
const run = promisify(execFile);
|
|
5
7
|
export const setup = {
|
|
8
|
+
widenPath() {
|
|
9
|
+
const current = (process.env.PATH ?? '').split(path.delimiter).filter((entry) => entry !== '');
|
|
10
|
+
const missing = toolDirs().filter((entry) => !current.includes(entry));
|
|
11
|
+
if (missing.length > 0)
|
|
12
|
+
process.env.PATH = [...current, ...missing].join(path.delimiter);
|
|
13
|
+
},
|
|
6
14
|
async run({ install, rewire, say, }) {
|
|
7
15
|
const found = await probe.tools();
|
|
8
16
|
if (!install)
|
|
9
|
-
return found;
|
|
17
|
+
return withReasons(found);
|
|
10
18
|
const repairs = { ...REPAIRS, ...(rewire === undefined ? {} : { brain: { name: 'the brain', fix: rewire } }) };
|
|
11
|
-
const broken = found.filter((tool) => !tool.ok && repairs[tool.id] !== undefined);
|
|
19
|
+
const broken = found.filter((tool) => !tool.ok && repairs[tool.id] !== undefined && isDue(tool.id));
|
|
12
20
|
if (broken.length === 0)
|
|
13
|
-
return found;
|
|
21
|
+
return withReasons(found);
|
|
14
22
|
for (const tool of broken) {
|
|
15
23
|
const repair = repairs[tool.id];
|
|
16
24
|
if (repair === undefined)
|
|
17
25
|
continue;
|
|
18
26
|
say?.(`Setting up ${repair.name}`);
|
|
27
|
+
tried.set(tool.id, Date.now());
|
|
19
28
|
const failed = await repair.fix().then(() => null, (error) => firstLine(error.message));
|
|
29
|
+
if (failed === null)
|
|
30
|
+
reasons.delete(tool.id);
|
|
31
|
+
else
|
|
32
|
+
reasons.set(tool.id, failed);
|
|
20
33
|
say?.(failed === null ? ` ${repair.name} is ready` : ` could not set up ${repair.name}: ${failed}`);
|
|
21
34
|
}
|
|
22
|
-
return probe.tools();
|
|
35
|
+
return withReasons(await probe.tools());
|
|
23
36
|
},
|
|
24
37
|
};
|
|
38
|
+
function isDue(id) {
|
|
39
|
+
const last = tried.get(id);
|
|
40
|
+
return last === undefined || Date.now() - last > RETRY_AFTER_MS;
|
|
41
|
+
}
|
|
42
|
+
function withReasons(tools) {
|
|
43
|
+
return tools.map((tool) => {
|
|
44
|
+
const reason = tool.ok ? undefined : reasons.get(tool.id);
|
|
45
|
+
return reason === undefined ? tool : { ...tool, detail: `${tool.detail} · ${reason}`.slice(0, 200) };
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function toolDirs() {
|
|
49
|
+
const prefix = process.env.npm_config_prefix;
|
|
50
|
+
return [
|
|
51
|
+
path.dirname(process.execPath),
|
|
52
|
+
...(prefix === undefined ? [] : [path.join(prefix, 'bin')]),
|
|
53
|
+
path.join(os.homedir(), '.local', 'bin'),
|
|
54
|
+
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
55
|
+
'/opt/homebrew/bin',
|
|
56
|
+
'/usr/local/bin',
|
|
57
|
+
];
|
|
58
|
+
}
|
|
25
59
|
const REPAIRS = {
|
|
26
60
|
claude: { name: 'Claude Code', fix: () => npmGlobal('@anthropic-ai/claude-code') },
|
|
27
61
|
codex: { name: 'Codex', fix: () => npmGlobal('@openai/codex') },
|
|
28
|
-
git: {
|
|
62
|
+
git: {
|
|
63
|
+
name: 'the GitHub CLI',
|
|
64
|
+
fix: async () => {
|
|
65
|
+
if ((await firstAvailable(['gh'])) !== null)
|
|
66
|
+
return;
|
|
67
|
+
await installPackage({ brew: ['gh'], apt: ['gh'], dnf: ['gh'] });
|
|
68
|
+
},
|
|
69
|
+
},
|
|
29
70
|
chrome: {
|
|
30
71
|
name: 'Google Chrome',
|
|
31
72
|
fix: () => installPackage({ brew: ['--cask', 'google-chrome'], apt: ['chromium'], dnf: ['chromium'] }),
|
|
@@ -56,4 +97,7 @@ async function firstAvailable(commands) {
|
|
|
56
97
|
function firstLine(message) {
|
|
57
98
|
return message.split('\n')[0] ?? 'it failed';
|
|
58
99
|
}
|
|
100
|
+
const tried = new Map();
|
|
101
|
+
const reasons = new Map();
|
|
59
102
|
const INSTALL_TIMEOUT_MS = 180_000;
|
|
103
|
+
const RETRY_AFTER_MS = 21_600_000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@craftspace/cli",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Sign a Mac or Linux machine into Craftspace and keep its agent setup in step.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,7 @@
|
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@craftspace/shared": "*",
|
|
33
|
+
"@inquirer/prompts": "^8.7.2",
|
|
33
34
|
"@types/node": "^22.0.0",
|
|
34
35
|
"commander": "^14.0.0",
|
|
35
36
|
"esbuild": "^0.28.0",
|