@craftspace/cli 0.1.0 → 0.2.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
@@ -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`;
@@ -466,6 +532,7 @@ const MAX_BACKOFF_MS = 120_000;
466
532
  const KEYS_BEGIN = '# craftspace begin';
467
533
  const KEYS_END = '# craftspace end';
468
534
  const POLL_INTERVAL_MS = 1_000;
535
+ const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
469
536
  const PROBE_EVERY_MS = 300_000;
470
537
  let probed = null;
471
538
  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
@@ -74,10 +74,8 @@ export function headlessArgv(work) {
74
74
  const argv = work.argv;
75
75
  if (!isAgent(argv[0]))
76
76
  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
77
  if (argv.includes('--remote-control'))
80
- return argv;
78
+ return underTty(argv);
81
79
  const streaming = ['--output-format', 'stream-json', '--verbose'];
82
80
  if (work.answer !== null && work.agentSessionId !== null) {
83
81
  return [argv[0], '--resume', work.agentSessionId, '-p', work.answer, ...streaming];
@@ -86,6 +84,11 @@ export function headlessArgv(work) {
86
84
  return [...argv, ...streaming];
87
85
  return [argv[0], '-p', argv.slice(1).join(' '), ...streaming];
88
86
  }
87
+ function underTty(argv) {
88
+ return process.platform === 'linux'
89
+ ? ['script', '-qec', argv.join(' '), '/dev/null']
90
+ : ['script', '-q', '/dev/null', ...argv];
91
+ }
89
92
  function isAgent(command) {
90
93
  return AGENTS.has((command ?? '').split('/').pop() ?? '');
91
94
  }
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.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",
@@ -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",