@craftspace/cli 0.4.3 → 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/dist/index.js +459 -291
- package/dist/machine.d.ts +1 -1
- package/dist/machine.js +112 -22
- package/dist/probe.d.ts +0 -1
- package/dist/probe.js +3 -66
- package/dist/run.d.ts +2 -1
- package/dist/run.js +2 -2
- package/dist/setup.d.ts +9 -5
- package/dist/setup.js +91 -80
- package/package.json +1 -1
package/dist/machine.d.ts
CHANGED
package/dist/machine.js
CHANGED
|
@@ -3,9 +3,10 @@ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, MachineSchema, MachineSessionSchema, MachinesResponseSchema, } from '@craftspace/shared';
|
|
6
|
+
import { CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, MachineSchema, MachineRepoSchema, MachineSessionSchema, MachinesResponseSchema, WorkstationStepResultSchema, } from '@craftspace/shared';
|
|
7
7
|
import { confirm, input, select } from '@inquirer/prompts';
|
|
8
8
|
import { z } from 'zod';
|
|
9
|
+
import { probe } from './probe.js';
|
|
9
10
|
import { runner } from './run.js';
|
|
10
11
|
import { setup } from './setup.js';
|
|
11
12
|
import { ui } from './ui.js';
|
|
@@ -37,10 +38,8 @@ export const machine = {
|
|
|
37
38
|
await writeFile(tokenPath(), token, { mode: 0o600 });
|
|
38
39
|
await writeAgentConfigs({ url, token, writes: answer.write });
|
|
39
40
|
await installService();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
tools: await setup.run({ install, rewire, say: ui.say }),
|
|
43
|
-
};
|
|
41
|
+
if (install)
|
|
42
|
+
await machine.beatOnce(ui.say);
|
|
44
43
|
return answer.machine;
|
|
45
44
|
},
|
|
46
45
|
async controlPlane(override) {
|
|
@@ -74,8 +73,9 @@ export const machine = {
|
|
|
74
73
|
const found = await withAuth(await signedInOrAsk(), (auth) => call({ ...auth, method: 'GET', path: '/api/machines/me', schema: MachineSchema }));
|
|
75
74
|
return { machine: found.value, url: found.auth.url };
|
|
76
75
|
},
|
|
77
|
-
async beatOnce() {
|
|
76
|
+
async beatOnce(say) {
|
|
78
77
|
const { url, token } = await requireSignedIn();
|
|
78
|
+
const built = await readEnvironment();
|
|
79
79
|
const answer = await call({
|
|
80
80
|
url,
|
|
81
81
|
token,
|
|
@@ -86,18 +86,27 @@ export const machine = {
|
|
|
86
86
|
...load(),
|
|
87
87
|
sessions: await readSessions(),
|
|
88
88
|
runs: drainReports(),
|
|
89
|
-
tools: await
|
|
89
|
+
tools: await checks(),
|
|
90
|
+
environment: built?.results ?? [],
|
|
91
|
+
templateId: built?.templateId ?? null,
|
|
92
|
+
workDir: setup.workDir(),
|
|
93
|
+
repos: built?.repos ?? [],
|
|
90
94
|
sshUser: os.userInfo().username,
|
|
91
95
|
},
|
|
92
96
|
schema: MachineBeatResponseSchema,
|
|
93
97
|
});
|
|
94
98
|
await writeAuthorizedKeys(answer.authorizedKeys);
|
|
99
|
+
const building = buildEnvironment(answer.environment, say);
|
|
100
|
+
if (say === undefined)
|
|
101
|
+
void building.catch(() => undefined);
|
|
102
|
+
else
|
|
103
|
+
await building;
|
|
95
104
|
for (const work of answer.work) {
|
|
96
105
|
if (running.has(work.id))
|
|
97
106
|
continue;
|
|
98
107
|
running.add(work.id);
|
|
99
108
|
void runner
|
|
100
|
-
.unattended({ work, onProgress: report })
|
|
109
|
+
.unattended({ work, cwd: startIn(built), onProgress: report })
|
|
101
110
|
.then(report)
|
|
102
111
|
.catch((error) => report({ id: work.id, state: 'failed', output: messageOf(error) }))
|
|
103
112
|
.finally(() => running.delete(work.id));
|
|
@@ -109,7 +118,7 @@ export const machine = {
|
|
|
109
118
|
let busy = false;
|
|
110
119
|
let checkedAt = 0;
|
|
111
120
|
for (;;) {
|
|
112
|
-
if (!busy && Date.now() - checkedAt > UPDATE_EVERY_MS) {
|
|
121
|
+
if (!busy && (failures > 2 || Date.now() - checkedAt > UPDATE_EVERY_MS)) {
|
|
113
122
|
checkedAt = Date.now();
|
|
114
123
|
const moved = await update.toLatest();
|
|
115
124
|
if (moved !== null) {
|
|
@@ -170,7 +179,7 @@ async function pick(machines) {
|
|
|
170
179
|
choices: machines.map((candidate) => ({
|
|
171
180
|
name: `${candidate.name.padEnd(24)} ${candidate.state.padEnd(12)} ${specs(candidate)} ${beat(candidate)}`,
|
|
172
181
|
value: candidate,
|
|
173
|
-
|
|
182
|
+
disabled: candidate.sshAddress === null ? '(no address yet, so nothing to open)' : false,
|
|
174
183
|
})),
|
|
175
184
|
pageSize: 12,
|
|
176
185
|
});
|
|
@@ -218,13 +227,75 @@ function nowhereToGo({ url }) {
|
|
|
218
227
|
ui.say('\n');
|
|
219
228
|
return 1;
|
|
220
229
|
}
|
|
221
|
-
async function
|
|
230
|
+
async function checks() {
|
|
222
231
|
if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
|
|
223
232
|
return probed.tools;
|
|
224
|
-
const
|
|
233
|
+
const first = await probe.tools();
|
|
234
|
+
const unwired = first.some((tool) => tool.id === 'brain' && !tool.ok);
|
|
235
|
+
const found = unwired && installs() ? await rewire().then(() => probe.tools(), () => first) : first;
|
|
225
236
|
probed = { at: Date.now(), tools: found };
|
|
226
237
|
return found;
|
|
227
238
|
}
|
|
239
|
+
async function buildEnvironment(next, say) {
|
|
240
|
+
if (building || !installs() || next.templateId === null)
|
|
241
|
+
return;
|
|
242
|
+
const built = await readEnvironment();
|
|
243
|
+
const buildsSteps = buildIsDue(built, next.templateId);
|
|
244
|
+
const clonesRepos = cloneIsDue(built, next.repos);
|
|
245
|
+
if (!buildsSteps && !clonesRepos)
|
|
246
|
+
return;
|
|
247
|
+
building = true;
|
|
248
|
+
try {
|
|
249
|
+
let results = built?.results ?? [];
|
|
250
|
+
let repos = built?.repos ?? [];
|
|
251
|
+
if (buildsSteps) {
|
|
252
|
+
say?.(ui.heading(`Building the ${next.steps.length} setup scripts your team runs on every box`));
|
|
253
|
+
results = await setup.runSteps({ steps: next.steps, say });
|
|
254
|
+
}
|
|
255
|
+
if (clonesRepos) {
|
|
256
|
+
say?.(ui.heading(`Cloning the ${next.repos.length} repos your team works in, into ${setup.workDir()}`));
|
|
257
|
+
repos = await setup.cloneRepos({ repos: next.repos, say });
|
|
258
|
+
await acceptClaudeGates(['craftspace'], repos.filter((repo) => repo.ok).map((repo) => repo.path));
|
|
259
|
+
}
|
|
260
|
+
await writeEnvironment({ templateId: next.templateId, results, repos, at: Date.now() });
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
building = false;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function cloneIsDue(built, wanted) {
|
|
267
|
+
const cloned = built?.repos ?? [];
|
|
268
|
+
if (wanted.length === 0 && cloned.length === 0)
|
|
269
|
+
return false;
|
|
270
|
+
if (wanted.length !== cloned.length)
|
|
271
|
+
return true;
|
|
272
|
+
const missing = wanted.some((repo) => !cloned.some((seen) => seen.name === repo.name && seen.ref === repo.ref && seen.ok));
|
|
273
|
+
return missing || Date.now() - (built?.at ?? 0) > REBUILD_AFTER_MS;
|
|
274
|
+
}
|
|
275
|
+
function startIn(built) {
|
|
276
|
+
return built?.repos.find((repo) => repo.ok)?.path ?? os.homedir();
|
|
277
|
+
}
|
|
278
|
+
function buildIsDue(built, templateId) {
|
|
279
|
+
if (built === null || built.templateId !== templateId)
|
|
280
|
+
return true;
|
|
281
|
+
if (built.results.every((result) => result.ok))
|
|
282
|
+
return false;
|
|
283
|
+
return Date.now() - built.at > REBUILD_AFTER_MS;
|
|
284
|
+
}
|
|
285
|
+
async function readEnvironment() {
|
|
286
|
+
const raw = await readFile(environmentPath(), 'utf8').catch(() => null);
|
|
287
|
+
if (raw === null)
|
|
288
|
+
return null;
|
|
289
|
+
const parsed = BuiltSchema.safeParse(JSON.parse(raw));
|
|
290
|
+
return parsed.success ? parsed.data : null;
|
|
291
|
+
}
|
|
292
|
+
async function writeEnvironment(built) {
|
|
293
|
+
await mkdir(machine.home(), { recursive: true, mode: 0o700 });
|
|
294
|
+
await writeFile(environmentPath(), JSON.stringify(built, null, 2), { mode: 0o600 });
|
|
295
|
+
}
|
|
296
|
+
function installs() {
|
|
297
|
+
return process.env.CRAFTSPACE_NO_INSTALL !== '1';
|
|
298
|
+
}
|
|
228
299
|
async function rewire() {
|
|
229
300
|
const { url, token } = await requireSignedIn();
|
|
230
301
|
await writeAgentConfigs({ url, token, writes: [] });
|
|
@@ -357,22 +428,29 @@ async function writeAgentConfigs({ url, token, writes, }) {
|
|
|
357
428
|
function serversIn(writes) {
|
|
358
429
|
return writes.flatMap((write) => isPlainObject(write.contents.mcpServers) ? Object.keys(write.contents.mcpServers) : []);
|
|
359
430
|
}
|
|
360
|
-
async function acceptClaudeGates(servers) {
|
|
431
|
+
async function acceptClaudeGates(servers, dirs = [os.homedir()]) {
|
|
361
432
|
const target = path.join(os.homedir(), '.claude.json');
|
|
362
433
|
const raw = await readFile(target, 'utf8').catch(() => null);
|
|
363
434
|
if (raw === null)
|
|
364
435
|
return;
|
|
365
436
|
const parsed = JSON.parse(raw);
|
|
366
437
|
const projects = isPlainObject(parsed.projects) ? parsed.projects : {};
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
438
|
+
const opened = { ...projects };
|
|
439
|
+
let moved = false;
|
|
440
|
+
for (const dir of dirs) {
|
|
441
|
+
const found = opened[dir];
|
|
442
|
+
const here = isPlainObject(found) ? { ...found } : {};
|
|
443
|
+
const enabled = new Set([...asArray(here.enabledMcpjsonServers), ...servers]);
|
|
444
|
+
if (here.hasTrustDialogAccepted === true && enabled.size === asArray(here.enabledMcpjsonServers).length)
|
|
445
|
+
continue;
|
|
446
|
+
here.hasTrustDialogAccepted = true;
|
|
447
|
+
here.enabledMcpjsonServers = [...enabled];
|
|
448
|
+
opened[dir] = here;
|
|
449
|
+
moved = true;
|
|
450
|
+
}
|
|
451
|
+
if (!moved)
|
|
371
452
|
return;
|
|
372
|
-
|
|
373
|
-
here.enabledMcpjsonServers = [...enabled];
|
|
374
|
-
const next = { ...parsed, projects: { ...projects, [os.homedir()]: here } };
|
|
375
|
-
await writeFile(target, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
453
|
+
await writeFile(target, `${JSON.stringify({ ...parsed, projects: opened }, null, 2)}\n`, { mode: 0o600 });
|
|
376
454
|
}
|
|
377
455
|
function asArray(value) {
|
|
378
456
|
return Array.isArray(value) ? value : [];
|
|
@@ -426,7 +504,8 @@ async function installService() {
|
|
|
426
504
|
await writeFile(path.join(dir, `${SERVICE_NAME}.service`), systemdUnit());
|
|
427
505
|
await run('loginctl', ['enable-linger', os.userInfo().username]).catch(() => undefined);
|
|
428
506
|
await run('systemctl', ['--user', 'daemon-reload']).catch(() => undefined);
|
|
429
|
-
await run('systemctl', ['--user', 'enable',
|
|
507
|
+
await run('systemctl', ['--user', 'enable', SERVICE_NAME]).catch(() => undefined);
|
|
508
|
+
await run('systemctl', ['--user', 'restart', SERVICE_NAME]).catch(() => undefined);
|
|
430
509
|
return;
|
|
431
510
|
}
|
|
432
511
|
if (process.platform === 'darwin') {
|
|
@@ -503,6 +582,9 @@ function tokenPath() {
|
|
|
503
582
|
function ownedPath() {
|
|
504
583
|
return path.join(machine.home(), 'owned.json');
|
|
505
584
|
}
|
|
585
|
+
function environmentPath() {
|
|
586
|
+
return path.join(machine.home(), 'environment.json');
|
|
587
|
+
}
|
|
506
588
|
function load() {
|
|
507
589
|
const cores = Math.max(1, os.cpus().length);
|
|
508
590
|
const busy = Math.min(100, ((os.loadavg()[0] ?? 0) / cores) * 100);
|
|
@@ -521,6 +603,12 @@ function messageOf(error) {
|
|
|
521
603
|
return error instanceof Error ? error.message : String(error);
|
|
522
604
|
}
|
|
523
605
|
const ConfigSchema = z.object({ url: z.string(), machineId: z.string() });
|
|
606
|
+
const BuiltSchema = z.object({
|
|
607
|
+
templateId: z.string(),
|
|
608
|
+
results: z.array(WorkstationStepResultSchema),
|
|
609
|
+
repos: z.array(MachineRepoSchema).default([]),
|
|
610
|
+
at: z.number(),
|
|
611
|
+
});
|
|
524
612
|
const OwnedSchema = z.object({ paths: z.array(z.string()) });
|
|
525
613
|
const SERVICE_NAME = 'craftspace';
|
|
526
614
|
const LAUNCH_LABEL = 'app.craftspace.machine';
|
|
@@ -549,6 +637,8 @@ const PORTABLE_TERMS = new Set([
|
|
|
549
637
|
const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
|
|
550
638
|
const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
|
|
551
639
|
const PROBE_EVERY_MS = 300_000;
|
|
640
|
+
const REBUILD_AFTER_MS = 21_600_000;
|
|
552
641
|
let probed = null;
|
|
642
|
+
let building = false;
|
|
553
643
|
const reports = new Map();
|
|
554
644
|
const running = new Set();
|
package/dist/probe.d.ts
CHANGED
package/dist/probe.js
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
2
1
|
import net from 'node:net';
|
|
3
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
4
3
|
import os from 'node:os';
|
|
5
4
|
import path from 'node:path';
|
|
6
|
-
import { promisify } from 'node:util';
|
|
7
|
-
const run = promisify(execFile);
|
|
8
5
|
export const probe = {
|
|
9
6
|
async tools({ mcpPath = path.join(os.homedir(), '.mcp.json') } = {}) {
|
|
10
|
-
|
|
11
|
-
brainWired(mcpPath),
|
|
12
|
-
version({ id: 'claude', command: 'claude' }),
|
|
13
|
-
version({ id: 'codex', command: 'codex' }),
|
|
14
|
-
gitAndGithub(),
|
|
15
|
-
chromeInstalled(),
|
|
16
|
-
sshListening(),
|
|
17
|
-
]);
|
|
18
|
-
return [brain, claude, codex, git, chrome, ssh];
|
|
7
|
+
return Promise.all([brainWired(mcpPath), sshListening()]);
|
|
19
8
|
},
|
|
20
9
|
};
|
|
21
10
|
async function brainWired(mcpPath) {
|
|
@@ -31,43 +20,6 @@ async function brainWired(mcpPath) {
|
|
|
31
20
|
detail: wired ? short(mcpPath) : `${short(mcpPath)} has no craftspace entry`,
|
|
32
21
|
};
|
|
33
22
|
}
|
|
34
|
-
async function version({ id, command }) {
|
|
35
|
-
const found = await firstLine(command, ['--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
|
-
}
|
|
40
|
-
async function gitAndGithub() {
|
|
41
|
-
const git = await firstLine('git', ['--version']);
|
|
42
|
-
if (!git.ran)
|
|
43
|
-
return { id: 'git', ok: false, detail: 'git is not installed' };
|
|
44
|
-
const gh = await firstLine('gh', ['--version']);
|
|
45
|
-
if (!gh.ran)
|
|
46
|
-
return { id: 'git', ok: false, detail: `${git.line} · the GitHub CLI is not installed yet` };
|
|
47
|
-
const account = await githubAccount();
|
|
48
|
-
return {
|
|
49
|
-
id: 'git',
|
|
50
|
-
ok: account !== null,
|
|
51
|
-
detail: account === null
|
|
52
|
-
? `${git.line} · ${gh.line}, run gh auth login on this machine`
|
|
53
|
-
: `${git.line} · ${gh.line}, signed in as ${account}`,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
async function githubAccount() {
|
|
57
|
-
const raw = await readFile(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8').catch(() => null);
|
|
58
|
-
return raw === null ? null : githubUser(raw);
|
|
59
|
-
}
|
|
60
|
-
export function githubUser(hostsYml) {
|
|
61
|
-
return /^ {4}user: *(\S+) *$/m.exec(hostsYml)?.[1] ?? null;
|
|
62
|
-
}
|
|
63
|
-
async function chromeInstalled() {
|
|
64
|
-
for (const command of [...MAC_CHROME, ...LINUX_CHROME]) {
|
|
65
|
-
const found = await firstLine(command, ['--version']);
|
|
66
|
-
if (found.ran)
|
|
67
|
-
return { id: 'chrome', ok: true, detail: found.line };
|
|
68
|
-
}
|
|
69
|
-
return { id: 'chrome', ok: false, detail: 'not installed yet, so browser runs will fail' };
|
|
70
|
-
}
|
|
71
23
|
function sshListening() {
|
|
72
24
|
return new Promise((resolve) => {
|
|
73
25
|
const socket = net.connect({ host: '127.0.0.1', port: 22 });
|
|
@@ -78,25 +30,10 @@ function sshListening() {
|
|
|
78
30
|
socket.setTimeout(SSH_PROBE_TIMEOUT_MS);
|
|
79
31
|
socket.on('connect', () => answer(true, 'sshd is listening on port 22'));
|
|
80
32
|
socket.on('timeout', () => answer(false, 'nothing answered on port 22'));
|
|
81
|
-
socket.on('error', () => answer(false, 'sshd is not running, so nobody can
|
|
33
|
+
socket.on('error', () => answer(false, 'sshd is not running, so nobody can open a terminal here'));
|
|
82
34
|
});
|
|
83
35
|
}
|
|
84
|
-
async function firstLine(command, args) {
|
|
85
|
-
if (command.includes('/')) {
|
|
86
|
-
const reachable = await access(command).then(() => true, () => false);
|
|
87
|
-
if (!reachable)
|
|
88
|
-
return { ran: false, why: 'absent' };
|
|
89
|
-
}
|
|
90
|
-
const answer = await run(command, args, { timeout: PROBE_TIMEOUT_MS }).catch((thrown) => thrown);
|
|
91
|
-
if (answer instanceof Error)
|
|
92
|
-
return { ran: false, why: answer.code === 'ENOENT' ? 'absent' : 'broken' };
|
|
93
|
-
const line = answer.stdout.split('\n')[0]?.trim() ?? '';
|
|
94
|
-
return line === '' ? { ran: false, why: 'broken' } : { ran: true, line: line.slice(0, 200) };
|
|
95
|
-
}
|
|
96
36
|
function short(target) {
|
|
97
37
|
return target.startsWith(os.homedir()) ? `~${target.slice(os.homedir().length)}` : target;
|
|
98
38
|
}
|
|
99
|
-
const MAC_CHROME = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
|
|
100
|
-
const LINUX_CHROME = ['google-chrome', 'google-chrome-stable', 'chromium'];
|
|
101
|
-
const PROBE_TIMEOUT_MS = 5_000;
|
|
102
39
|
const SSH_PROBE_TIMEOUT_MS = 1_500;
|
package/dist/run.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { type MachineRunReport, type MachineWork } from '@craftspace/shared';
|
|
2
2
|
export declare const runner: {
|
|
3
3
|
attended(argv: string[]): Promise<number>;
|
|
4
|
-
unattended({ work, onProgress, }: {
|
|
4
|
+
unattended({ work, cwd, onProgress, }: {
|
|
5
5
|
work: MachineWork;
|
|
6
|
+
cwd?: string;
|
|
6
7
|
onProgress?: (report: MachineRunReport) => void;
|
|
7
8
|
}): Promise<MachineRunReport>;
|
|
8
9
|
};
|
package/dist/run.js
CHANGED
|
@@ -9,7 +9,7 @@ export const runner = {
|
|
|
9
9
|
child.on('close', (code) => resolve(code ?? 0));
|
|
10
10
|
});
|
|
11
11
|
},
|
|
12
|
-
async unattended({ work, onProgress, }) {
|
|
12
|
+
async unattended({ work, cwd = os.homedir(), onProgress, }) {
|
|
13
13
|
const argv = headlessArgv(work);
|
|
14
14
|
const raw = argv[0] === 'script';
|
|
15
15
|
let output = '';
|
|
@@ -37,7 +37,7 @@ export const runner = {
|
|
|
37
37
|
const code = await new Promise((resolve) => {
|
|
38
38
|
const child = spawn(argv[0], argv.slice(1), {
|
|
39
39
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
40
|
-
cwd
|
|
40
|
+
cwd,
|
|
41
41
|
env: {
|
|
42
42
|
...process.env,
|
|
43
43
|
TERM: process.env.TERM ?? 'xterm-256color',
|
package/dist/setup.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type MachineRepo, type WorkstationRepo, type WorkstationStep, type WorkstationStepResult } from '@craftspace/shared';
|
|
2
2
|
export declare const setup: {
|
|
3
3
|
widenPath(): void;
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
workDir(): string;
|
|
5
|
+
cloneRepos({ repos, say, }: {
|
|
6
|
+
repos: WorkstationRepo[];
|
|
7
7
|
say?: (line: string) => void;
|
|
8
|
-
}): Promise<
|
|
8
|
+
}): Promise<MachineRepo[]>;
|
|
9
|
+
runSteps({ steps, say, }: {
|
|
10
|
+
steps: WorkstationStep[];
|
|
11
|
+
say?: (line: string) => void;
|
|
12
|
+
}): Promise<WorkstationStepResult[]>;
|
|
9
13
|
};
|
package/dist/setup.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir } from 'node:fs/promises';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
4
|
-
import {
|
|
5
|
-
import { probe } from './probe.js';
|
|
5
|
+
import { WORKSTATION_STEP_DETAIL_MAX_CHARS, } from '@craftspace/shared';
|
|
6
6
|
import { ui } from './ui.js';
|
|
7
|
-
const run = promisify(execFile);
|
|
8
7
|
export const setup = {
|
|
9
8
|
widenPath() {
|
|
10
9
|
const current = (process.env.PATH ?? '').split(path.delimiter).filter((entry) => entry !== '');
|
|
@@ -12,44 +11,95 @@ export const setup = {
|
|
|
12
11
|
if (missing.length > 0)
|
|
13
12
|
process.env.PATH = [...current, ...missing].join(path.delimiter);
|
|
14
13
|
},
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
say?.(ui.
|
|
29
|
-
tried.set(tool.id, Date.now());
|
|
30
|
-
const failed = await repair.fix().then(() => null, (error) => firstLine(error.message));
|
|
31
|
-
if (failed === null)
|
|
32
|
-
reasons.delete(tool.id);
|
|
33
|
-
else
|
|
34
|
-
reasons.set(tool.id, failed);
|
|
35
|
-
if (failed === null)
|
|
36
|
-
ready += 1;
|
|
37
|
-
say?.(`${ui.clear()}${failed === null ? ui.ok(repair.name) : ui.bad(repair.name, failed)}`);
|
|
14
|
+
workDir() {
|
|
15
|
+
return path.join(os.homedir(), WORK_DIR_NAME);
|
|
16
|
+
},
|
|
17
|
+
async cloneRepos({ repos, say, }) {
|
|
18
|
+
if (repos.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
await mkdir(setup.workDir(), { recursive: true });
|
|
21
|
+
const cloned = [];
|
|
22
|
+
for (const [index, repo] of repos.entries()) {
|
|
23
|
+
say?.(ui.progress({ name: repo.name, done: index, total: repos.length }));
|
|
24
|
+
const at = path.join(setup.workDir(), repo.name.split('/').pop() ?? repo.name);
|
|
25
|
+
const outcome = await checkout(repo, at);
|
|
26
|
+
cloned.push({ name: repo.name, ref: repo.ref, path: at, ...outcome });
|
|
27
|
+
say?.(`${ui.clear()}${outcome.ok ? ui.ok(`${repo.name} · ${outcome.detail}`) : ui.bad(repo.name, outcome.detail)}`);
|
|
38
28
|
}
|
|
39
|
-
say?.(ui.tally({ done:
|
|
40
|
-
return
|
|
29
|
+
say?.(ui.tally({ done: cloned.filter((repo) => repo.ok).length, total: cloned.length, noun: 'cloned' }));
|
|
30
|
+
return cloned;
|
|
31
|
+
},
|
|
32
|
+
async runSteps({ steps, say, }) {
|
|
33
|
+
const results = [];
|
|
34
|
+
for (const [index, step] of steps.entries()) {
|
|
35
|
+
say?.(ui.progress({ name: step.name, done: index, total: steps.length }));
|
|
36
|
+
const startedAt = Date.now();
|
|
37
|
+
const outcome = await runScript(step.script);
|
|
38
|
+
results.push({ ...outcome, id: step.id, name: step.name, ms: Date.now() - startedAt });
|
|
39
|
+
say?.(`${ui.clear()}${outcome.ok ? ui.ok(step.name) : ui.bad(step.name, outcome.detail)}`);
|
|
40
|
+
}
|
|
41
|
+
if (results.length > 0) {
|
|
42
|
+
say?.(ui.tally({ done: results.filter((result) => result.ok).length, total: results.length, noun: 'ready' }));
|
|
43
|
+
}
|
|
44
|
+
return results;
|
|
41
45
|
},
|
|
42
46
|
};
|
|
43
|
-
function
|
|
44
|
-
const
|
|
45
|
-
|
|
47
|
+
async function checkout(repo, at) {
|
|
48
|
+
const opened = await git(['-C', at, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
49
|
+
if (!opened.ok) {
|
|
50
|
+
const url = `https://github.com/${repo.name}.git`;
|
|
51
|
+
const made = await git(['clone', '--depth', String(CLONE_DEPTH), '--branch', repo.ref, url, at]);
|
|
52
|
+
if (!made.ok)
|
|
53
|
+
return { ok: false, detail: made.detail };
|
|
54
|
+
return { ok: true, detail: await state({ repo, at, branch: repo.ref, dirty: false }) };
|
|
55
|
+
}
|
|
56
|
+
const branch = opened.detail;
|
|
57
|
+
const dirty = (await git(['-C', at, 'status', '--porcelain'])).detail !== '';
|
|
58
|
+
const pulled = branch === repo.ref && !dirty ? await git(['-C', at, 'pull', '--ff-only']) : null;
|
|
59
|
+
return { ok: true, detail: await state({ repo, at, branch, dirty, pulled }) };
|
|
46
60
|
}
|
|
47
|
-
function
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
async function state({ repo, at, branch, dirty, pulled, }) {
|
|
62
|
+
const head = await git(['-C', at, 'rev-parse', '--short', 'HEAD']);
|
|
63
|
+
return [
|
|
64
|
+
`${branch} @ ${head.detail}`,
|
|
65
|
+
branch === repo.ref ? '' : `your team asks for ${repo.ref}`,
|
|
66
|
+
dirty ? 'uncommitted work here, so it was left alone' : '',
|
|
67
|
+
pulled?.ok === false ? `could not pull: ${pulled.detail}` : '',
|
|
68
|
+
]
|
|
69
|
+
.filter((part) => part !== '')
|
|
70
|
+
.join(' · ');
|
|
71
|
+
}
|
|
72
|
+
function git(argv) {
|
|
73
|
+
return new Promise((resolve) => {
|
|
74
|
+
execFile('git', argv, {
|
|
75
|
+
timeout: CLONE_TIMEOUT_MS,
|
|
76
|
+
maxBuffer: SCRIPT_BUFFER_BYTES,
|
|
77
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: '', GIT_LFS_SKIP_SMUDGE: '1' },
|
|
78
|
+
}, (error, stdout, stderr) => {
|
|
79
|
+
if (error === null)
|
|
80
|
+
return resolve({ ok: true, detail: stdout.trim() });
|
|
81
|
+
resolve({ ok: false, detail: lastLines(`${stderr}\n${stdout}`) || error.message.split('\n')[0] || 'git failed' });
|
|
82
|
+
});
|
|
51
83
|
});
|
|
52
84
|
}
|
|
85
|
+
function runScript(script) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
execFile('/bin/sh', ['-e', '-c', script], { timeout: STEP_TIMEOUT_MS, maxBuffer: SCRIPT_BUFFER_BYTES }, (error, stdout, stderr) => {
|
|
88
|
+
if (error === null)
|
|
89
|
+
return resolve({ ok: true, detail: lastLines(stdout) });
|
|
90
|
+
resolve({ ok: false, detail: lastLines(`${stderr}\n${stdout}`) || error.message.split('\n')[0] || 'it failed' });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function lastLines(output) {
|
|
95
|
+
return output
|
|
96
|
+
.split('\n')
|
|
97
|
+
.map((line) => line.trim())
|
|
98
|
+
.filter((line) => line !== '')
|
|
99
|
+
.slice(-KEPT_LINES)
|
|
100
|
+
.join(' · ')
|
|
101
|
+
.slice(0, WORKSTATION_STEP_DETAIL_MAX_CHARS);
|
|
102
|
+
}
|
|
53
103
|
function toolDirs() {
|
|
54
104
|
const prefix = process.env.npm_config_prefix;
|
|
55
105
|
return [
|
|
@@ -61,48 +111,9 @@ function toolDirs() {
|
|
|
61
111
|
'/usr/local/bin',
|
|
62
112
|
];
|
|
63
113
|
}
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if ((await firstAvailable(['gh'])) !== null)
|
|
71
|
-
return;
|
|
72
|
-
await installPackage({ brew: ['gh'], apt: ['gh'], dnf: ['gh'] });
|
|
73
|
-
},
|
|
74
|
-
},
|
|
75
|
-
chrome: {
|
|
76
|
-
name: 'Google Chrome',
|
|
77
|
-
fix: () => installPackage({ brew: ['--cask', 'google-chrome'], apt: ['chromium'], dnf: ['chromium'] }),
|
|
78
|
-
},
|
|
79
|
-
};
|
|
80
|
-
async function npmGlobal(name) {
|
|
81
|
-
await run('npm', ['install', '-g', name], { timeout: INSTALL_TIMEOUT_MS });
|
|
82
|
-
}
|
|
83
|
-
async function installPackage({ brew, apt, dnf }) {
|
|
84
|
-
if (process.platform === 'darwin') {
|
|
85
|
-
await run('brew', ['install', ...brew], { timeout: INSTALL_TIMEOUT_MS });
|
|
86
|
-
return;
|
|
87
|
-
}
|
|
88
|
-
const manager = await firstAvailable(['apt-get', 'dnf']);
|
|
89
|
-
if (manager === null)
|
|
90
|
-
throw new Error('no package manager this CLI knows how to drive');
|
|
91
|
-
const packages = manager === 'apt-get' ? apt : dnf;
|
|
92
|
-
await run('sudo', ['-n', manager, 'install', '-y', ...packages], { timeout: INSTALL_TIMEOUT_MS });
|
|
93
|
-
}
|
|
94
|
-
async function firstAvailable(commands) {
|
|
95
|
-
for (const command of commands) {
|
|
96
|
-
const there = await run('/bin/sh', ['-c', `command -v ${command}`]).then(() => true, () => false);
|
|
97
|
-
if (there)
|
|
98
|
-
return command;
|
|
99
|
-
}
|
|
100
|
-
return null;
|
|
101
|
-
}
|
|
102
|
-
function firstLine(message) {
|
|
103
|
-
return message.split('\n')[0] ?? 'it failed';
|
|
104
|
-
}
|
|
105
|
-
const tried = new Map();
|
|
106
|
-
const reasons = new Map();
|
|
107
|
-
const INSTALL_TIMEOUT_MS = 180_000;
|
|
108
|
-
const RETRY_AFTER_MS = 21_600_000;
|
|
114
|
+
const WORK_DIR_NAME = 'craftspace';
|
|
115
|
+
const CLONE_DEPTH = 20;
|
|
116
|
+
const CLONE_TIMEOUT_MS = 900_000;
|
|
117
|
+
const STEP_TIMEOUT_MS = 600_000;
|
|
118
|
+
const SCRIPT_BUFFER_BYTES = 4 * 1024 * 1024;
|
|
119
|
+
const KEPT_LINES = 3;
|