@craftspace/cli 0.2.2 → 0.4.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/index.js +819 -256
- package/dist/machine.d.ts +6 -5
- package/dist/machine.js +77 -128
- package/dist/setup.js +8 -3
- package/dist/ui.d.ts +29 -0
- package/dist/ui.js +57 -0
- package/dist/update.d.ts +5 -0
- package/dist/update.js +46 -0
- package/package.json +3 -1
package/dist/machine.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export declare const machine: {
|
|
|
6
6
|
url: string;
|
|
7
7
|
install: boolean;
|
|
8
8
|
}): Promise<Machine>;
|
|
9
|
+
url(): Promise<string | null>;
|
|
9
10
|
signIn({ url, why, install }: {
|
|
10
11
|
url?: string;
|
|
11
12
|
why: string;
|
|
@@ -17,11 +18,11 @@ export declare const machine: {
|
|
|
17
18
|
}>;
|
|
18
19
|
beatOnce(): Promise<boolean>;
|
|
19
20
|
beatForever(): Promise<void>;
|
|
20
|
-
run(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
run(): Promise<number>;
|
|
22
|
+
daemon(): Promise<{
|
|
23
|
+
text: string;
|
|
24
|
+
ok: boolean;
|
|
25
|
+
}>;
|
|
25
26
|
logout(): Promise<void>;
|
|
26
27
|
};
|
|
27
28
|
export declare function tokenIn(line: string): string | undefined;
|
package/dist/machine.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
-
import { execFile } from 'node:child_process';
|
|
1
|
+
import { execFile, spawn } from 'node:child_process';
|
|
2
2
|
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,
|
|
6
|
+
import { CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, 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,22 +39,25 @@ export const machine = {
|
|
|
37
39
|
await installService();
|
|
38
40
|
probed = {
|
|
39
41
|
at: Date.now(),
|
|
40
|
-
tools: await setup.run({ install, rewire, say:
|
|
42
|
+
tools: await setup.run({ install, rewire, say: ui.say }),
|
|
41
43
|
};
|
|
42
44
|
return answer.machine;
|
|
43
45
|
},
|
|
46
|
+
async url() {
|
|
47
|
+
return (await readConfig())?.url ?? null;
|
|
48
|
+
},
|
|
44
49
|
async signIn({ url, why, install = false }) {
|
|
45
50
|
const where = url ?? (await readConfig())?.url ?? DEFAULT_URL;
|
|
46
51
|
if (!process.stdin.isTTY)
|
|
47
|
-
throw new Error(`${why} Run:
|
|
52
|
+
throw new Error(`${why} Run: cs login --token <token>`);
|
|
48
53
|
const page = `${where}/workstations`;
|
|
49
|
-
|
|
54
|
+
ui.say(`\n${ui.attention(why)}\n\n`);
|
|
50
55
|
if (await confirm({ message: `Open ${page} for a key?`, default: true })) {
|
|
51
56
|
await openInBrowser(page);
|
|
52
|
-
|
|
57
|
+
ui.say(ui.note('Click "Add a workstation" there, then copy the line it gives you.'));
|
|
53
58
|
}
|
|
54
59
|
else {
|
|
55
|
-
|
|
60
|
+
ui.say(ui.note(`Get one at ${ui.link(page)} under "Add a workstation".`));
|
|
56
61
|
}
|
|
57
62
|
const token = tokenIn(await input({
|
|
58
63
|
message: 'Paste it here',
|
|
@@ -61,7 +66,7 @@ export const machine = {
|
|
|
61
66
|
if (token === undefined)
|
|
62
67
|
throw new Error('That line carries no key.');
|
|
63
68
|
const signed = await machine.login({ token, url: where, install });
|
|
64
|
-
|
|
69
|
+
ui.say(`\n${ui.ok(`Signed in as ${signed.ownerName}. This machine is ${ui.name(signed.name)}.`)}`);
|
|
65
70
|
return { url: where, token, machine: signed };
|
|
66
71
|
},
|
|
67
72
|
async status() {
|
|
@@ -101,7 +106,16 @@ export const machine = {
|
|
|
101
106
|
async beatForever() {
|
|
102
107
|
let failures = 0;
|
|
103
108
|
let busy = false;
|
|
109
|
+
let checkedAt = 0;
|
|
104
110
|
for (;;) {
|
|
111
|
+
if (!busy && Date.now() - checkedAt > UPDATE_EVERY_MS) {
|
|
112
|
+
checkedAt = Date.now();
|
|
113
|
+
const moved = await update.toLatest();
|
|
114
|
+
if (moved !== null) {
|
|
115
|
+
process.stderr.write(`craftspace ${CLI_VERSION} -> ${moved}, restarting\n`);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
105
119
|
try {
|
|
106
120
|
busy = await machine.beatOnce();
|
|
107
121
|
failures = 0;
|
|
@@ -113,21 +127,23 @@ export const machine = {
|
|
|
113
127
|
await new Promise((resolve) => setTimeout(resolve, nextDelayMs(failures, busy)));
|
|
114
128
|
}
|
|
115
129
|
},
|
|
116
|
-
async run(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
130
|
+
async run() {
|
|
131
|
+
const first = await signedInOrAsk();
|
|
132
|
+
const { auth, value: answer } = await withAuth(first, (live) => call({ ...live, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema }));
|
|
133
|
+
if (answer.machines.length === 0)
|
|
134
|
+
return nowhereToGo(auth);
|
|
135
|
+
return openTerminal(await pick(answer.machines));
|
|
120
136
|
},
|
|
121
|
-
async
|
|
137
|
+
async daemon() {
|
|
122
138
|
if (process.env.CRAFTSPACE_NO_SERVICE === '1')
|
|
123
|
-
return `not installed. Start it with: ${here()} daemon
|
|
139
|
+
return { text: `not installed. Start it with: ${here()} daemon`, ok: false };
|
|
124
140
|
if (process.platform !== 'linux')
|
|
125
|
-
return BEATING;
|
|
141
|
+
return { text: BEATING, ok: true };
|
|
126
142
|
if (!(await serviceRunning()))
|
|
127
|
-
return `not running. Start it with: ${here()} daemon
|
|
143
|
+
return { text: `not running. Start it with: ${here()} daemon`, ok: false };
|
|
128
144
|
if (!(await userLingers()))
|
|
129
|
-
return `beating, but it stops when you log out. Run: ${LINGER_LINE}
|
|
130
|
-
return BEATING;
|
|
145
|
+
return { text: `beating, but it stops when you log out. Run: ${LINGER_LINE}`, ok: false };
|
|
146
|
+
return { text: BEATING, ok: true };
|
|
131
147
|
},
|
|
132
148
|
async logout() {
|
|
133
149
|
const config = await readConfig();
|
|
@@ -144,116 +160,57 @@ async function requireSignedIn() {
|
|
|
144
160
|
const config = await readConfig();
|
|
145
161
|
const token = await readToken();
|
|
146
162
|
if (!config || !token)
|
|
147
|
-
throw new Error('This machine is not signed in. Run:
|
|
163
|
+
throw new Error('This machine is not signed in. Run: cs login --token <token>');
|
|
148
164
|
return { url: config.url, token };
|
|
149
165
|
}
|
|
150
|
-
async function
|
|
151
|
-
const id = `local-${Date.now().toString(36)}`;
|
|
152
|
-
await writeSession({ id, state: 'working', title: argv.join(' ').slice(0, 200) });
|
|
153
|
-
try {
|
|
154
|
-
return await runner.attended(argv);
|
|
155
|
-
}
|
|
156
|
-
finally {
|
|
157
|
-
await clearSession(id);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
async function runThere({ argv, on }) {
|
|
161
|
-
const first = await signedInOrAsk();
|
|
162
|
-
const { auth, value: target } = await withAuth(first, (live) => machineNamed({ ...live, name: on }));
|
|
163
|
-
const { url, token } = auth;
|
|
164
|
-
let run = await call({
|
|
165
|
-
url,
|
|
166
|
-
token,
|
|
167
|
-
method: 'POST',
|
|
168
|
-
path: `/api/machines/${target.id}/runs`,
|
|
169
|
-
body: { argv },
|
|
170
|
-
schema: MachineRunSchema,
|
|
171
|
-
});
|
|
172
|
-
process.stdout.write(`${target.name} · queued ${run.id}\n\n`);
|
|
173
|
-
let printed = 0;
|
|
174
|
-
for (;;) {
|
|
175
|
-
if (run.output.length > printed) {
|
|
176
|
-
process.stdout.write(run.output.slice(printed));
|
|
177
|
-
printed = run.output.length;
|
|
178
|
-
}
|
|
179
|
-
if (run.state === 'done')
|
|
180
|
-
return 0;
|
|
181
|
-
if (run.state === 'failed')
|
|
182
|
-
return run.exitCode ?? 1;
|
|
183
|
-
if (run.state === 'needs_input') {
|
|
184
|
-
process.stdout.write(`\n${target.name} is asking: ${run.question ?? 'it needs a decision'}\n> `);
|
|
185
|
-
const reply = await readStdinLine();
|
|
186
|
-
if (reply === null)
|
|
187
|
-
return 130;
|
|
188
|
-
run = await call({
|
|
189
|
-
url,
|
|
190
|
-
token,
|
|
191
|
-
method: 'POST',
|
|
192
|
-
path: `/api/machines/runs/${run.id}/answer`,
|
|
193
|
-
body: { text: reply },
|
|
194
|
-
schema: MachineRunSchema,
|
|
195
|
-
});
|
|
196
|
-
printed = run.output.length;
|
|
197
|
-
continue;
|
|
198
|
-
}
|
|
199
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
200
|
-
run = await readRun({ url, token, machineId: target.id, runId: run.id });
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
async function readRun({ url, token, machineId, runId, }) {
|
|
204
|
-
const answer = await call({
|
|
205
|
-
url,
|
|
206
|
-
token,
|
|
207
|
-
method: 'GET',
|
|
208
|
-
path: `/api/machines/${machineId}/runs`,
|
|
209
|
-
schema: MachineRunsResponseSchema,
|
|
210
|
-
});
|
|
211
|
-
const found = answer.runs.find((candidate) => candidate.id === runId);
|
|
212
|
-
if (found === undefined)
|
|
213
|
-
throw new Error(`Run ${runId} is gone.`);
|
|
214
|
-
return found;
|
|
215
|
-
}
|
|
216
|
-
async function machineNamed({ url, token, name, }) {
|
|
217
|
-
const answer = await call({ url, token, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema });
|
|
218
|
-
const found = answer.machines.find((candidate) => candidate.name === name || candidate.id === name);
|
|
219
|
-
if (found !== undefined)
|
|
220
|
-
return found;
|
|
221
|
-
if (answer.machines.length === 0)
|
|
222
|
-
throw new Error('This team has no workstations yet. Add one at /workstations.');
|
|
223
|
-
if (!process.stdin.isTTY) {
|
|
224
|
-
throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
|
|
225
|
-
}
|
|
226
|
-
process.stdout.write(`\nNo workstation called ${name}.\n\n`);
|
|
166
|
+
async function pick(machines) {
|
|
227
167
|
return select({
|
|
228
|
-
message: '
|
|
229
|
-
choices:
|
|
230
|
-
name: `${candidate.name.padEnd(
|
|
168
|
+
message: 'Which workstation?',
|
|
169
|
+
choices: machines.map((candidate) => ({
|
|
170
|
+
name: `${candidate.name.padEnd(24)} ${candidate.state.padEnd(12)} ${specs(candidate)} ${beat(candidate)}`,
|
|
231
171
|
value: candidate,
|
|
172
|
+
description: candidate.sshAddress === null ? 'No address yet, so nothing to open' : undefined,
|
|
232
173
|
})),
|
|
174
|
+
pageSize: 12,
|
|
233
175
|
});
|
|
234
176
|
}
|
|
235
|
-
function
|
|
177
|
+
function specs({ platform, cores, memoryGb }) {
|
|
178
|
+
return [platform, cores === null ? null : `${cores} vCPU`, memoryGb === null ? null : `${memoryGb} GB`]
|
|
179
|
+
.filter((part) => part !== null)
|
|
180
|
+
.join(' \u00b7 ');
|
|
181
|
+
}
|
|
182
|
+
function beat({ lastBeatAt }) {
|
|
183
|
+
if (lastBeatAt === null)
|
|
184
|
+
return 'never beat';
|
|
185
|
+
const seconds = Math.max(0, Math.round((Date.now() - new Date(lastBeatAt).getTime()) / 1000));
|
|
186
|
+
if (seconds < 60)
|
|
187
|
+
return `beat ${seconds}s ago`;
|
|
188
|
+
if (seconds < 3600)
|
|
189
|
+
return `beat ${Math.round(seconds / 60)}m ago`;
|
|
190
|
+
return `beat ${Math.round(seconds / 3600)}h ago`;
|
|
191
|
+
}
|
|
192
|
+
async function openTerminal(target) {
|
|
193
|
+
if (target.sshAddress === null) {
|
|
194
|
+
throw new Error(`${target.name} has not told us an address yet. It reports one on its next beat.`);
|
|
195
|
+
}
|
|
196
|
+
const where = `${target.sshUser ?? 'root'}@${target.sshAddress}`;
|
|
197
|
+
ui.say(`\n${ui.dim('\u2192')} ${ui.name(target.name)} ${ui.dim(`\u00b7 ${where}`)}\n\n`);
|
|
236
198
|
return new Promise((resolve) => {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
resolve(line.trim() === '' ? null : line.trim());
|
|
244
|
-
};
|
|
245
|
-
const onData = (chunk) => {
|
|
246
|
-
buffer += chunk;
|
|
247
|
-
const at = buffer.indexOf('\n');
|
|
248
|
-
if (at !== -1)
|
|
249
|
-
done(buffer.slice(0, at));
|
|
250
|
-
};
|
|
251
|
-
const onEnd = () => done(buffer);
|
|
252
|
-
process.stdin.on('data', onData);
|
|
253
|
-
process.stdin.on('end', onEnd);
|
|
254
|
-
process.stdin.resume();
|
|
199
|
+
const session = spawn('ssh', ['-t', where], { stdio: 'inherit' });
|
|
200
|
+
session.on('error', (error) => {
|
|
201
|
+
ui.say(ui.bad(`could not start ssh`, error.message));
|
|
202
|
+
resolve(1);
|
|
203
|
+
});
|
|
204
|
+
session.on('exit', (code) => resolve(code ?? 0));
|
|
255
205
|
});
|
|
256
206
|
}
|
|
207
|
+
function nowhereToGo({ url }) {
|
|
208
|
+
ui.say(`\n${ui.attention('No workstations here yet.')}\n\n`);
|
|
209
|
+
ui.say(ui.field('Add one', ui.link(`${url}/workstations`)));
|
|
210
|
+
ui.say(ui.field('Or sign in', 'a Mac or Linux box with: cs login'));
|
|
211
|
+
ui.say('\n');
|
|
212
|
+
return 1;
|
|
213
|
+
}
|
|
257
214
|
async function tools() {
|
|
258
215
|
if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
|
|
259
216
|
return probed.tools;
|
|
@@ -314,7 +271,7 @@ async function withAuth(auth, work) {
|
|
|
314
271
|
url: auth.url,
|
|
315
272
|
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
273
|
});
|
|
317
|
-
|
|
274
|
+
ui.say(`\n${ui.note('Picking up where you left off.')}\n`);
|
|
318
275
|
return { auth: next, value: await work(next) };
|
|
319
276
|
}
|
|
320
277
|
}
|
|
@@ -374,14 +331,6 @@ async function writeAuthorizedKeys(keys) {
|
|
|
374
331
|
function trimEnd(text) {
|
|
375
332
|
return text.replace(/\s+$/, '');
|
|
376
333
|
}
|
|
377
|
-
async function writeSession(session) {
|
|
378
|
-
const dir = path.join(machine.home(), 'sessions');
|
|
379
|
-
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
380
|
-
await writeFile(path.join(dir, `${session.id}.json`), JSON.stringify(session), { mode: 0o600 });
|
|
381
|
-
}
|
|
382
|
-
async function clearSession(id) {
|
|
383
|
-
await rm(path.join(machine.home(), 'sessions', `${id}.json`), { force: true });
|
|
384
|
-
}
|
|
385
334
|
async function writeAgentConfigs({ url, token, writes, }) {
|
|
386
335
|
const planned = writes.length > 0 ? writes : defaultWrites({ url, token });
|
|
387
336
|
const owned = [];
|
|
@@ -573,7 +522,7 @@ const MAX_REPORTED_SESSIONS = 50;
|
|
|
573
522
|
const MAX_BACKOFF_MS = 120_000;
|
|
574
523
|
const KEYS_BEGIN = '# craftspace begin';
|
|
575
524
|
const KEYS_END = '# craftspace end';
|
|
576
|
-
const
|
|
525
|
+
const UPDATE_EVERY_MS = 6 * 60 * 60 * 1_000;
|
|
577
526
|
const BEATING = 'beating every 15s, outbound only, and keeps this set up';
|
|
578
527
|
const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
|
|
579
528
|
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
|
-
|
|
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?.(
|
|
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
|
-
|
|
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;
|
package/dist/update.d.ts
ADDED
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.
|
|
3
|
+
"version": "0.4.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",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
"node": ">=20"
|
|
15
15
|
},
|
|
16
16
|
"bin": {
|
|
17
|
+
"cs": "dist/index.js",
|
|
17
18
|
"craftspace": "dist/index.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
@@ -32,6 +33,7 @@
|
|
|
32
33
|
"@craftspace/shared": "*",
|
|
33
34
|
"@inquirer/prompts": "^8.7.2",
|
|
34
35
|
"@types/node": "^22.0.0",
|
|
36
|
+
"chalk": "^5.6.0",
|
|
35
37
|
"commander": "^14.0.0",
|
|
36
38
|
"esbuild": "^0.28.0",
|
|
37
39
|
"typescript": "^5.6.0"
|