@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/machine.d.ts CHANGED
@@ -16,7 +16,7 @@ export declare const machine: {
16
16
  machine: Machine;
17
17
  url: string;
18
18
  }>;
19
- beatOnce(): Promise<boolean>;
19
+ beatOnce(say?: (line: string) => void): Promise<boolean>;
20
20
  beatForever(): Promise<void>;
21
21
  run(): Promise<number>;
22
22
  daemon(): Promise<{
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
- probed = {
41
- at: Date.now(),
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 tools(),
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
- description: candidate.sshAddress === null ? 'No address yet, so nothing to open' : undefined,
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 tools() {
230
+ async function checks() {
222
231
  if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
223
232
  return probed.tools;
224
- const found = await setup.run({ install: process.env.CRAFTSPACE_NO_INSTALL !== '1', rewire });
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 found = projects[os.homedir()];
368
- const here = isPlainObject(found) ? { ...found } : {};
369
- const enabled = new Set([...(Array.isArray(here.enabledMcpjsonServers) ? here.enabledMcpjsonServers : []), ...servers]);
370
- if (here.hasTrustDialogAccepted === true && enabled.size === asArray(here.enabledMcpjsonServers).length)
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
- here.hasTrustDialogAccepted = true;
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', '--now', SERVICE_NAME]).catch(() => undefined);
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
@@ -4,4 +4,3 @@ export declare const probe: {
4
4
  mcpPath?: string;
5
5
  }): Promise<MachineTool[]>;
6
6
  };
7
- export declare function githubUser(hostsYml: string): string | null;
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 { access, readFile } from 'node:fs/promises';
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
- const [brain, claude, codex, git, chrome, ssh] = await Promise.all([
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 ssh in'));
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: os.homedir(),
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 { MachineTool } from '@craftspace/shared';
1
+ import { type MachineRepo, type WorkstationRepo, type WorkstationStep, type WorkstationStepResult } from '@craftspace/shared';
2
2
  export declare const setup: {
3
3
  widenPath(): void;
4
- run({ install, rewire, say, }: {
5
- install: boolean;
6
- rewire?: () => Promise<void>;
4
+ workDir(): string;
5
+ cloneRepos({ repos, say, }: {
6
+ repos: WorkstationRepo[];
7
7
  say?: (line: string) => void;
8
- }): Promise<MachineTool[]>;
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 { promisify } from 'node:util';
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
- async run({ install, rewire, say, }) {
16
- const found = await probe.tools();
17
- if (!install)
18
- return withReasons(found);
19
- const repairs = { ...REPAIRS, ...(rewire === undefined ? {} : { brain: { name: 'the brain', fix: rewire } }) };
20
- const broken = found.filter((tool) => !tool.ok && repairs[tool.id] !== undefined && isDue(tool.id));
21
- if (broken.length === 0)
22
- return withReasons(found);
23
- let ready = 0;
24
- for (const [index, tool] of broken.entries()) {
25
- const repair = repairs[tool.id];
26
- if (repair === undefined)
27
- continue;
28
- say?.(ui.progress({ name: repair.name, done: index, total: broken.length }));
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: ready, total: broken.length, noun: 'ready' }));
40
- return withReasons(await probe.tools());
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 isDue(id) {
44
- const last = tried.get(id);
45
- return last === undefined || Date.now() - last > RETRY_AFTER_MS;
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 withReasons(tools) {
48
- return tools.map((tool) => {
49
- const reason = tool.ok ? undefined : reasons.get(tool.id);
50
- return reason === undefined ? tool : { ...tool, detail: `${tool.detail} · ${reason}`.slice(0, 200) };
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 REPAIRS = {
65
- claude: { name: 'Claude Code', fix: () => npmGlobal('@anthropic-ai/claude-code') },
66
- codex: { name: 'Codex', fix: () => npmGlobal('@openai/codex') },
67
- git: {
68
- name: 'the GitHub CLI',
69
- fix: async () => {
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftspace/cli",
3
- "version": "0.4.3",
3
+ "version": "0.6.0",
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",