@craftspace/cli 0.4.2 → 0.5.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<{
@@ -25,6 +25,7 @@ export declare const machine: {
25
25
  }>;
26
26
  logout(): Promise<void>;
27
27
  };
28
+ export declare function portableTerm(local: string | undefined): string;
28
29
  export declare function tokenIn(line: string): string | undefined;
29
30
  export declare function nextDelayMs(failures: number, busy?: boolean): number;
30
31
  interface Auth {
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, 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,12 +86,19 @@ 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,
90
92
  sshUser: os.userInfo().username,
91
93
  },
92
94
  schema: MachineBeatResponseSchema,
93
95
  });
94
96
  await writeAuthorizedKeys(answer.authorizedKeys);
97
+ const building = buildEnvironment(answer.environment, say);
98
+ if (say === undefined)
99
+ void building.catch(() => undefined);
100
+ else
101
+ await building;
95
102
  for (const work of answer.work) {
96
103
  if (running.has(work.id))
97
104
  continue;
@@ -170,7 +177,7 @@ async function pick(machines) {
170
177
  choices: machines.map((candidate) => ({
171
178
  name: `${candidate.name.padEnd(24)} ${candidate.state.padEnd(12)} ${specs(candidate)} ${beat(candidate)}`,
172
179
  value: candidate,
173
- description: candidate.sshAddress === null ? 'No address yet, so nothing to open' : undefined,
180
+ disabled: candidate.sshAddress === null ? '(no address yet, so nothing to open)' : false,
174
181
  })),
175
182
  pageSize: 12,
176
183
  });
@@ -197,7 +204,10 @@ async function openTerminal(target) {
197
204
  const where = `${target.sshUser ?? 'root'}@${target.sshAddress}`;
198
205
  ui.say(`\n${ui.dim('\u2192')} ${ui.name(target.name)} ${ui.dim(`\u00b7 ${where}`)}\n\n`);
199
206
  return new Promise((resolve) => {
200
- const session = spawn('ssh', ['-t', where], { stdio: 'inherit' });
207
+ const session = spawn('ssh', ['-t', where], {
208
+ stdio: 'inherit',
209
+ env: { ...process.env, TERM: portableTerm(process.env.TERM) },
210
+ });
201
211
  session.on('error', (error) => {
202
212
  ui.say(ui.bad(`could not start ssh`, error.message));
203
213
  resolve(1);
@@ -205,6 +215,9 @@ async function openTerminal(target) {
205
215
  session.on('exit', (code) => resolve(code ?? 0));
206
216
  });
207
217
  }
218
+ export function portableTerm(local) {
219
+ return local !== undefined && PORTABLE_TERMS.has(local) ? local : 'xterm-256color';
220
+ }
208
221
  function nowhereToGo({ url }) {
209
222
  ui.say(`\n${ui.attention('No workstations here yet.')}\n\n`);
210
223
  ui.say(ui.field('Add one', ui.link(`${url}/workstations`)));
@@ -212,13 +225,52 @@ function nowhereToGo({ url }) {
212
225
  ui.say('\n');
213
226
  return 1;
214
227
  }
215
- async function tools() {
228
+ async function checks() {
216
229
  if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
217
230
  return probed.tools;
218
- const found = await setup.run({ install: process.env.CRAFTSPACE_NO_INSTALL !== '1', rewire });
231
+ const first = await probe.tools();
232
+ const unwired = first.some((tool) => tool.id === 'brain' && !tool.ok);
233
+ const found = unwired && installs() ? await rewire().then(() => probe.tools(), () => first) : first;
219
234
  probed = { at: Date.now(), tools: found };
220
235
  return found;
221
236
  }
237
+ async function buildEnvironment(next, say) {
238
+ if (building || !installs() || next.templateId === null)
239
+ return;
240
+ const built = await readEnvironment();
241
+ if (!buildIsDue(built, next.templateId))
242
+ return;
243
+ building = true;
244
+ try {
245
+ say?.(ui.heading(`Building the ${next.steps.length} setup scripts your team runs on every box`));
246
+ const results = await setup.runSteps({ steps: next.steps, say });
247
+ await writeEnvironment({ templateId: next.templateId, results, at: Date.now() });
248
+ }
249
+ finally {
250
+ building = false;
251
+ }
252
+ }
253
+ function buildIsDue(built, templateId) {
254
+ if (built === null || built.templateId !== templateId)
255
+ return true;
256
+ if (built.results.every((result) => result.ok))
257
+ return false;
258
+ return Date.now() - built.at > REBUILD_AFTER_MS;
259
+ }
260
+ async function readEnvironment() {
261
+ const raw = await readFile(environmentPath(), 'utf8').catch(() => null);
262
+ if (raw === null)
263
+ return null;
264
+ const parsed = BuiltSchema.safeParse(JSON.parse(raw));
265
+ return parsed.success ? parsed.data : null;
266
+ }
267
+ async function writeEnvironment(built) {
268
+ await mkdir(machine.home(), { recursive: true, mode: 0o700 });
269
+ await writeFile(environmentPath(), JSON.stringify(built, null, 2), { mode: 0o600 });
270
+ }
271
+ function installs() {
272
+ return process.env.CRAFTSPACE_NO_INSTALL !== '1';
273
+ }
222
274
  async function rewire() {
223
275
  const { url, token } = await requireSignedIn();
224
276
  await writeAgentConfigs({ url, token, writes: [] });
@@ -497,6 +549,9 @@ function tokenPath() {
497
549
  function ownedPath() {
498
550
  return path.join(machine.home(), 'owned.json');
499
551
  }
552
+ function environmentPath() {
553
+ return path.join(machine.home(), 'environment.json');
554
+ }
500
555
  function load() {
501
556
  const cores = Math.max(1, os.cpus().length);
502
557
  const busy = Math.min(100, ((os.loadavg()[0] ?? 0) / cores) * 100);
@@ -515,6 +570,11 @@ function messageOf(error) {
515
570
  return error instanceof Error ? error.message : String(error);
516
571
  }
517
572
  const ConfigSchema = z.object({ url: z.string(), machineId: z.string() });
573
+ const BuiltSchema = z.object({
574
+ templateId: z.string(),
575
+ results: z.array(WorkstationStepResultSchema),
576
+ at: z.number(),
577
+ });
518
578
  const OwnedSchema = z.object({ paths: z.array(z.string()) });
519
579
  const SERVICE_NAME = 'craftspace';
520
580
  const LAUNCH_LABEL = 'app.craftspace.machine';
@@ -525,9 +585,26 @@ const KEYS_BEGIN = '# craftspace begin';
525
585
  const KEYS_END = '# craftspace end';
526
586
  const UPDATE_EVERY_MS = 6 * 60 * 60 * 1_000;
527
587
  const BEATING = 'beating every 15s, outbound only, and keeps this set up';
588
+ const PORTABLE_TERMS = new Set([
589
+ 'ansi',
590
+ 'dumb',
591
+ 'linux',
592
+ 'rxvt',
593
+ 'rxvt-unicode',
594
+ 'rxvt-unicode-256color',
595
+ 'screen',
596
+ 'screen-256color',
597
+ 'vt100',
598
+ 'vt220',
599
+ 'xterm',
600
+ 'xterm-color',
601
+ 'xterm-256color',
602
+ ]);
528
603
  const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
529
604
  const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
530
605
  const PROBE_EVERY_MS = 300_000;
606
+ const REBUILD_AFTER_MS = 21_600_000;
531
607
  let probed = null;
608
+ let building = false;
532
609
  const reports = new Map();
533
610
  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/setup.d.ts CHANGED
@@ -1,9 +1,8 @@
1
- import type { MachineTool } from '@craftspace/shared';
1
+ import { 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
+ runSteps({ steps, say, }: {
5
+ steps: WorkstationStep[];
7
6
  say?: (line: string) => void;
8
- }): Promise<MachineTool[]>;
7
+ }): Promise<WorkstationStepResult[]>;
9
8
  };
package/dist/setup.js CHANGED
@@ -1,10 +1,8 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
- import { promisify } from 'node:util';
5
- import { probe } from './probe.js';
4
+ import { WORKSTATION_STEP_DETAIL_MAX_CHARS, } from '@craftspace/shared';
6
5
  import { ui } from './ui.js';
7
- const run = promisify(execFile);
8
6
  export const setup = {
9
7
  widenPath() {
10
8
  const current = (process.env.PATH ?? '').split(path.delimiter).filter((entry) => entry !== '');
@@ -12,44 +10,39 @@ export const setup = {
12
10
  if (missing.length > 0)
13
11
  process.env.PATH = [...current, ...missing].join(path.delimiter);
14
12
  },
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)}`);
13
+ async runSteps({ steps, say, }) {
14
+ const results = [];
15
+ for (const [index, step] of steps.entries()) {
16
+ say?.(ui.progress({ name: step.name, done: index, total: steps.length }));
17
+ const startedAt = Date.now();
18
+ const outcome = await runScript(step.script);
19
+ results.push({ ...outcome, id: step.id, name: step.name, ms: Date.now() - startedAt });
20
+ say?.(`${ui.clear()}${outcome.ok ? ui.ok(step.name) : ui.bad(step.name, outcome.detail)}`);
38
21
  }
39
- say?.(ui.tally({ done: ready, total: broken.length, noun: 'ready' }));
40
- return withReasons(await probe.tools());
22
+ if (results.length > 0) {
23
+ say?.(ui.tally({ done: results.filter((result) => result.ok).length, total: results.length, noun: 'ready' }));
24
+ }
25
+ return results;
41
26
  },
42
27
  };
43
- function isDue(id) {
44
- const last = tried.get(id);
45
- return last === undefined || Date.now() - last > RETRY_AFTER_MS;
46
- }
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) };
28
+ function runScript(script) {
29
+ return new Promise((resolve) => {
30
+ execFile('/bin/sh', ['-e', '-c', script], { timeout: STEP_TIMEOUT_MS, maxBuffer: SCRIPT_BUFFER_BYTES }, (error, stdout, stderr) => {
31
+ if (error === null)
32
+ return resolve({ ok: true, detail: lastLines(stdout) });
33
+ resolve({ ok: false, detail: lastLines(`${stderr}\n${stdout}`) || error.message.split('\n')[0] || 'it failed' });
34
+ });
51
35
  });
52
36
  }
37
+ function lastLines(output) {
38
+ return output
39
+ .split('\n')
40
+ .map((line) => line.trim())
41
+ .filter((line) => line !== '')
42
+ .slice(-KEPT_LINES)
43
+ .join(' · ')
44
+ .slice(0, WORKSTATION_STEP_DETAIL_MAX_CHARS);
45
+ }
53
46
  function toolDirs() {
54
47
  const prefix = process.env.npm_config_prefix;
55
48
  return [
@@ -61,48 +54,6 @@ function toolDirs() {
61
54
  '/usr/local/bin',
62
55
  ];
63
56
  }
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;
57
+ const STEP_TIMEOUT_MS = 600_000;
58
+ const SCRIPT_BUFFER_BYTES = 4 * 1024 * 1024;
59
+ const KEPT_LINES = 3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftspace/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.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",