@craftspace/cli 0.2.2 → 0.3.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
@@ -17,11 +17,15 @@ export declare const machine: {
17
17
  }>;
18
18
  beatOnce(): Promise<boolean>;
19
19
  beatForever(): Promise<void>;
20
+ update(): Promise<void>;
20
21
  run({ argv, on }: {
21
22
  argv: string[];
22
23
  on?: string;
23
24
  }): Promise<number>;
24
- daemonLine(): Promise<string>;
25
+ daemon(): Promise<{
26
+ text: string;
27
+ ok: boolean;
28
+ }>;
25
29
  logout(): Promise<void>;
26
30
  };
27
31
  export declare function tokenIn(line: string): string | undefined;
package/dist/machine.js CHANGED
@@ -3,11 +3,13 @@ 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, MachineRunSchema, MachineRunsResponseSchema, MachineSchema, MachineSessionSchema, MachinesResponseSchema, } from '@craftspace/shared';
6
+ import { CLI_INSTALL, CLI_PACKAGE, CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, MachineRunSchema, MachineRunsResponseSchema, MachineSchema, MachineSessionSchema, MachinesResponseSchema, } from '@craftspace/shared';
7
7
  import { confirm, input, select } from '@inquirer/prompts';
8
8
  import { z } from 'zod';
9
9
  import { runner } from './run.js';
10
10
  import { setup } from './setup.js';
11
+ import { ui } from './ui.js';
12
+ import { update } from './update.js';
11
13
  const run = promisify(execFile);
12
14
  export const machine = {
13
15
  home() {
@@ -37,7 +39,7 @@ export const machine = {
37
39
  await installService();
38
40
  probed = {
39
41
  at: Date.now(),
40
- tools: await setup.run({ install, rewire, say: (text) => process.stdout.write(text) }),
42
+ tools: await setup.run({ install, rewire, say: ui.say }),
41
43
  };
42
44
  return answer.machine;
43
45
  },
@@ -46,13 +48,13 @@ export const machine = {
46
48
  if (!process.stdin.isTTY)
47
49
  throw new Error(`${why} Run: craftspace login --token <token>`);
48
50
  const page = `${where}/workstations`;
49
- process.stdout.write(`\n${why}\n\n`);
51
+ ui.say(`\n${ui.attention(why)}\n\n`);
50
52
  if (await confirm({ message: `Open ${page} for a key?`, default: true })) {
51
53
  await openInBrowser(page);
52
- process.stdout.write(' Click "Add a workstation" there, then copy the line it gives you.\n');
54
+ ui.say(ui.note('Click "Add a workstation" there, then copy the line it gives you.'));
53
55
  }
54
56
  else {
55
- process.stdout.write(` Get one at ${page} under "Add a workstation".\n`);
57
+ ui.say(ui.note(`Get one at ${ui.link(page)} under "Add a workstation".`));
56
58
  }
57
59
  const token = tokenIn(await input({
58
60
  message: 'Paste it here',
@@ -61,7 +63,7 @@ export const machine = {
61
63
  if (token === undefined)
62
64
  throw new Error('That line carries no key.');
63
65
  const signed = await machine.login({ token, url: where, install });
64
- process.stdout.write(`\nSigned in as ${signed.ownerName}. This machine is ${signed.name}.\n`);
66
+ ui.say(`\n${ui.ok(`Signed in as ${signed.ownerName}. This machine is ${ui.name(signed.name)}.`)}`);
65
67
  return { url: where, token, machine: signed };
66
68
  },
67
69
  async status() {
@@ -101,7 +103,16 @@ export const machine = {
101
103
  async beatForever() {
102
104
  let failures = 0;
103
105
  let busy = false;
106
+ let checkedAt = 0;
104
107
  for (;;) {
108
+ if (!busy && Date.now() - checkedAt > UPDATE_EVERY_MS) {
109
+ checkedAt = Date.now();
110
+ const moved = await update.toLatest();
111
+ if (moved !== null) {
112
+ process.stderr.write(`craftspace ${CLI_VERSION} -> ${moved}, restarting\n`);
113
+ return;
114
+ }
115
+ }
105
116
  try {
106
117
  busy = await machine.beatOnce();
107
118
  failures = 0;
@@ -113,21 +124,34 @@ export const machine = {
113
124
  await new Promise((resolve) => setTimeout(resolve, nextDelayMs(failures, busy)));
114
125
  }
115
126
  },
127
+ async update() {
128
+ ui.say(ui.waiting(`checking ${CLI_PACKAGE}`));
129
+ const moved = await update.latest();
130
+ if (moved === null) {
131
+ ui.say(`${ui.clear()}${ui.ok(`craftspace ${ui.name(CLI_VERSION)}, nothing newer on npm`)}`);
132
+ return;
133
+ }
134
+ ui.say(`${ui.clear()}${ui.waiting(`installing ${moved}`)}`);
135
+ const done = await update.toLatest();
136
+ ui.say(`${ui.clear()}${done === null
137
+ ? ui.bad(`could not install ${moved}`, `run it yourself: ${CLI_INSTALL}@latest`)
138
+ : ui.ok(`craftspace ${ui.name(CLI_VERSION)} -> ${ui.name(done)}`)}`);
139
+ },
116
140
  async run({ argv, on }) {
117
141
  if (on === undefined)
118
142
  return runHere(argv);
119
143
  return runThere({ argv, on });
120
144
  },
121
- async daemonLine() {
145
+ async daemon() {
122
146
  if (process.env.CRAFTSPACE_NO_SERVICE === '1')
123
- return `not installed. Start it with: ${here()} daemon`;
147
+ return { text: `not installed. Start it with: ${here()} daemon`, ok: false };
124
148
  if (process.platform !== 'linux')
125
- return BEATING;
149
+ return { text: BEATING, ok: true };
126
150
  if (!(await serviceRunning()))
127
- return `not running. Start it with: ${here()} daemon`;
151
+ return { text: `not running. Start it with: ${here()} daemon`, ok: false };
128
152
  if (!(await userLingers()))
129
- return `beating, but it stops when you log out. Run: ${LINGER_LINE}`;
130
- return BEATING;
153
+ return { text: `beating, but it stops when you log out. Run: ${LINGER_LINE}`, ok: false };
154
+ return { text: BEATING, ok: true };
131
155
  },
132
156
  async logout() {
133
157
  const config = await readConfig();
@@ -169,7 +193,7 @@ async function runThere({ argv, on }) {
169
193
  body: { argv },
170
194
  schema: MachineRunSchema,
171
195
  });
172
- process.stdout.write(`${target.name} · queued ${run.id}\n\n`);
196
+ ui.say(`${ui.name(target.name)} ${ui.dim(`· queued ${run.id}`)}\n\n`);
173
197
  let printed = 0;
174
198
  for (;;) {
175
199
  if (run.output.length > printed) {
@@ -181,7 +205,7 @@ async function runThere({ argv, on }) {
181
205
  if (run.state === 'failed')
182
206
  return run.exitCode ?? 1;
183
207
  if (run.state === 'needs_input') {
184
- process.stdout.write(`\n${target.name} is asking: ${run.question ?? 'it needs a decision'}\n> `);
208
+ ui.say(`\n${ui.attention(`${target.name} is asking:`)} ${run.question ?? 'it needs a decision'}\n> `);
185
209
  const reply = await readStdinLine();
186
210
  if (reply === null)
187
211
  return 130;
@@ -223,7 +247,7 @@ async function machineNamed({ url, token, name, }) {
223
247
  if (!process.stdin.isTTY) {
224
248
  throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
225
249
  }
226
- process.stdout.write(`\nNo workstation called ${name}.\n\n`);
250
+ ui.say(`\n${ui.attention(`No workstation called ${name}.`)}\n\n`);
227
251
  return select({
228
252
  message: 'Run it on',
229
253
  choices: answer.machines.map((candidate) => ({
@@ -314,7 +338,7 @@ async function withAuth(auth, work) {
314
338
  url: auth.url,
315
339
  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.',
316
340
  });
317
- process.stdout.write('\nPicking up where you left off.\n\n');
341
+ ui.say(`\n${ui.note('Picking up where you left off.')}\n`);
318
342
  return { auth: next, value: await work(next) };
319
343
  }
320
344
  }
@@ -574,6 +598,7 @@ const MAX_BACKOFF_MS = 120_000;
574
598
  const KEYS_BEGIN = '# craftspace begin';
575
599
  const KEYS_END = '# craftspace end';
576
600
  const POLL_INTERVAL_MS = 1_000;
601
+ const UPDATE_EVERY_MS = 6 * 60 * 60 * 1_000;
577
602
  const BEATING = 'beating every 15s, outbound only, and keeps this set up';
578
603
  const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
579
604
  const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
package/dist/setup.js CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  import { probe } from './probe.js';
6
+ import { ui } from './ui.js';
6
7
  const run = promisify(execFile);
7
8
  export const setup = {
8
9
  widenPath() {
@@ -19,19 +20,23 @@ export const setup = {
19
20
  const broken = found.filter((tool) => !tool.ok && repairs[tool.id] !== undefined && isDue(tool.id));
20
21
  if (broken.length === 0)
21
22
  return withReasons(found);
22
- for (const tool of broken) {
23
+ let ready = 0;
24
+ for (const [index, tool] of broken.entries()) {
23
25
  const repair = repairs[tool.id];
24
26
  if (repair === undefined)
25
27
  continue;
26
- say?.(`Setting up ${repair.name}`);
28
+ say?.(ui.progress({ name: repair.name, done: index, total: broken.length }));
27
29
  tried.set(tool.id, Date.now());
28
30
  const failed = await repair.fix().then(() => null, (error) => firstLine(error.message));
29
31
  if (failed === null)
30
32
  reasons.delete(tool.id);
31
33
  else
32
34
  reasons.set(tool.id, failed);
33
- say?.(failed === null ? ` ${repair.name} is ready` : ` could not set up ${repair.name}: ${failed}`);
35
+ if (failed === null)
36
+ ready += 1;
37
+ say?.(`${ui.clear()}${failed === null ? ui.ok(repair.name) : ui.bad(repair.name, failed)}`);
34
38
  }
39
+ say?.(ui.tally({ done: ready, total: broken.length, noun: 'ready' }));
35
40
  return withReasons(await probe.tools());
36
41
  },
37
42
  };
package/dist/ui.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export declare const ui: {
2
+ say(text: string): void;
3
+ heading(text: string): string;
4
+ field(label: string, value: string): string;
5
+ ok(text: string): string;
6
+ bad(text: string, why?: string): string;
7
+ note(text: string): string;
8
+ waiting(text: string): string;
9
+ clear(): string;
10
+ progress({ name, done, total }: {
11
+ name: string;
12
+ done: number;
13
+ total: number;
14
+ }): string;
15
+ bar({ done, total }: {
16
+ done: number;
17
+ total: number;
18
+ }): string;
19
+ tally({ done, total, noun }: {
20
+ done: number;
21
+ total: number;
22
+ noun: string;
23
+ }): string;
24
+ name(text: string): string;
25
+ link(text: string): string;
26
+ command(text: string): string;
27
+ dim(text: string): string;
28
+ attention(text: string): string;
29
+ };
package/dist/ui.js ADDED
@@ -0,0 +1,57 @@
1
+ import chalk from 'chalk';
2
+ export const ui = {
3
+ say(text) {
4
+ process.stdout.write(text);
5
+ },
6
+ heading(text) {
7
+ return `\n${chalk.bold(text)}\n`;
8
+ },
9
+ field(label, value) {
10
+ return ` ${chalk.dim(label.padEnd(LABEL_WIDTH))}${value}\n`;
11
+ },
12
+ ok(text) {
13
+ return ` ${chalk.green('✔')} ${text}\n`;
14
+ },
15
+ bad(text, why) {
16
+ return ` ${chalk.red('✖')} ${text}${why === undefined ? '' : chalk.dim(` · ${why}`)}\n`;
17
+ },
18
+ note(text) {
19
+ return ` ${chalk.dim(text)}\n`;
20
+ },
21
+ waiting(text) {
22
+ return fancy ? `\r\u001b[K ${chalk.dim(`… ${text}`)}` : ` ${chalk.dim(`… ${text}`)}\n`;
23
+ },
24
+ clear() {
25
+ return fancy ? '\r\u001b[K' : '';
26
+ },
27
+ progress({ name, done, total }) {
28
+ if (!fancy)
29
+ return ` ${chalk.dim(`… ${name}`)}\n`;
30
+ return `\r\u001b[K ${ui.bar({ done, total })} ${chalk.dim(`${name}…`)}`;
31
+ },
32
+ bar({ done, total }) {
33
+ const filled = Math.round((done / Math.max(total, 1)) * BAR_CELLS);
34
+ return `${chalk.green('█'.repeat(filled))}${chalk.dim('░'.repeat(BAR_CELLS - filled))}`;
35
+ },
36
+ tally({ done, total, noun }) {
37
+ return ` ${ui.bar({ done, total })} ${chalk.bold(`${done}/${total}`)} ${chalk.dim(noun)}\n`;
38
+ },
39
+ name(text) {
40
+ return chalk.cyan(text);
41
+ },
42
+ link(text) {
43
+ return chalk.underline(chalk.dim(text));
44
+ },
45
+ command(text) {
46
+ return chalk.yellow(text);
47
+ },
48
+ dim(text) {
49
+ return chalk.dim(text);
50
+ },
51
+ attention(text) {
52
+ return chalk.yellow(text);
53
+ },
54
+ };
55
+ const fancy = process.stdout.isTTY === true;
56
+ const LABEL_WIDTH = 13;
57
+ const BAR_CELLS = 10;
@@ -0,0 +1,5 @@
1
+ export declare const update: {
2
+ latest(): Promise<string | null>;
3
+ toLatest(): Promise<string | null>;
4
+ };
5
+ export declare function isNewer(candidate: string, current?: string): boolean;
package/dist/update.js ADDED
@@ -0,0 +1,46 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { CLI_PACKAGE, CLI_VERSION } from '@craftspace/shared';
4
+ const run = promisify(execFile);
5
+ export const update = {
6
+ async latest() {
7
+ const answer = await fetch(`${REGISTRY}/${CLI_PACKAGE}/latest`, {
8
+ headers: { accept: 'application/json' },
9
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
10
+ }).catch(() => null);
11
+ if (answer === null || !answer.ok)
12
+ return null;
13
+ const body = await answer.json().catch(() => null);
14
+ const version = isRecord(body) ? body.version : undefined;
15
+ return typeof version === 'string' && isNewer(version) ? version : null;
16
+ },
17
+ async toLatest() {
18
+ const version = await update.latest();
19
+ if (version === null)
20
+ return null;
21
+ const installed = await run('npm', ['install', '--global', '--no-fund', `${CLI_PACKAGE}@${version}`], {
22
+ timeout: INSTALL_TIMEOUT_MS,
23
+ }).then(() => true, () => false);
24
+ return installed ? version : null;
25
+ },
26
+ };
27
+ export function isNewer(candidate, current = CLI_VERSION) {
28
+ const mine = parts(current);
29
+ const theirs = parts(candidate);
30
+ for (let at = 0; at < 3; at += 1) {
31
+ const here = mine[at] ?? 0;
32
+ const there = theirs[at] ?? 0;
33
+ if (there !== here)
34
+ return there > here;
35
+ }
36
+ return false;
37
+ }
38
+ function parts(version) {
39
+ return (/^\d+\.\d+\.\d+/.exec(version.trim())?.[0] ?? '').split('.').map(Number);
40
+ }
41
+ function isRecord(value) {
42
+ return typeof value === 'object' && value !== null;
43
+ }
44
+ const REGISTRY = 'https://registry.npmjs.org';
45
+ const REQUEST_TIMEOUT_MS = 10_000;
46
+ const INSTALL_TIMEOUT_MS = 180_000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftspace/cli",
3
- "version": "0.2.2",
3
+ "version": "0.3.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",
@@ -32,6 +32,7 @@
32
32
  "@craftspace/shared": "*",
33
33
  "@inquirer/prompts": "^8.7.2",
34
34
  "@types/node": "^22.0.0",
35
+ "chalk": "^5.6.0",
35
36
  "commander": "^14.0.0",
36
37
  "esbuild": "^0.28.0",
37
38
  "typescript": "^5.6.0"