@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/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
- } | null>;
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 config = await readConfig();
45
- if (!config)
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 { url, token } = await requireSignedIn();
133
- const target = await machineNamed({ url, token, name: on });
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 === undefined) {
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
- return found;
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 (typeof found === 'string')
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 (typeof git !== 'string')
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 (typeof gh !== 'string')
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 (typeof found === 'string')
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), { stdio: ['ignore', 'pipe', 'pipe'] });
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
@@ -1,5 +1,6 @@
1
1
  import type { MachineTool } from '@craftspace/shared';
2
2
  export declare const setup: {
3
+ widenPath(): void;
3
4
  run({ install, rewire, say, }: {
4
5
  install: boolean;
5
6
  rewire?: () => Promise<void>;
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: { name: 'the GitHub CLI', fix: () => installPackage({ brew: ['gh'], apt: ['gh'], dnf: ['gh'] }) },
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.0",
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",