@craftspace/cli 0.2.1 → 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/index.js +832 -147
- package/dist/machine.d.ts +5 -0
- package/dist/machine.js +60 -13
- 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 +2 -1
package/dist/machine.d.ts
CHANGED
|
@@ -17,10 +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>;
|
|
25
|
+
daemon(): Promise<{
|
|
26
|
+
text: string;
|
|
27
|
+
ok: boolean;
|
|
28
|
+
}>;
|
|
24
29
|
logout(): Promise<void>;
|
|
25
30
|
};
|
|
26
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:
|
|
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
|
-
|
|
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
|
-
|
|
54
|
+
ui.say(ui.note('Click "Add a workstation" there, then copy the line it gives you.'));
|
|
53
55
|
}
|
|
54
56
|
else {
|
|
55
|
-
|
|
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
|
-
|
|
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,11 +124,35 @@ 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
|
},
|
|
145
|
+
async daemon() {
|
|
146
|
+
if (process.env.CRAFTSPACE_NO_SERVICE === '1')
|
|
147
|
+
return { text: `not installed. Start it with: ${here()} daemon`, ok: false };
|
|
148
|
+
if (process.platform !== 'linux')
|
|
149
|
+
return { text: BEATING, ok: true };
|
|
150
|
+
if (!(await serviceRunning()))
|
|
151
|
+
return { text: `not running. Start it with: ${here()} daemon`, ok: false };
|
|
152
|
+
if (!(await userLingers()))
|
|
153
|
+
return { text: `beating, but it stops when you log out. Run: ${LINGER_LINE}`, ok: false };
|
|
154
|
+
return { text: BEATING, ok: true };
|
|
155
|
+
},
|
|
121
156
|
async logout() {
|
|
122
157
|
const config = await readConfig();
|
|
123
158
|
const token = await readToken();
|
|
@@ -158,7 +193,7 @@ async function runThere({ argv, on }) {
|
|
|
158
193
|
body: { argv },
|
|
159
194
|
schema: MachineRunSchema,
|
|
160
195
|
});
|
|
161
|
-
|
|
196
|
+
ui.say(`${ui.name(target.name)} ${ui.dim(`· queued ${run.id}`)}\n\n`);
|
|
162
197
|
let printed = 0;
|
|
163
198
|
for (;;) {
|
|
164
199
|
if (run.output.length > printed) {
|
|
@@ -170,7 +205,7 @@ async function runThere({ argv, on }) {
|
|
|
170
205
|
if (run.state === 'failed')
|
|
171
206
|
return run.exitCode ?? 1;
|
|
172
207
|
if (run.state === 'needs_input') {
|
|
173
|
-
|
|
208
|
+
ui.say(`\n${ui.attention(`${target.name} is asking:`)} ${run.question ?? 'it needs a decision'}\n> `);
|
|
174
209
|
const reply = await readStdinLine();
|
|
175
210
|
if (reply === null)
|
|
176
211
|
return 130;
|
|
@@ -212,7 +247,7 @@ async function machineNamed({ url, token, name, }) {
|
|
|
212
247
|
if (!process.stdin.isTTY) {
|
|
213
248
|
throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
|
|
214
249
|
}
|
|
215
|
-
|
|
250
|
+
ui.say(`\n${ui.attention(`No workstation called ${name}.`)}\n\n`);
|
|
216
251
|
return select({
|
|
217
252
|
message: 'Run it on',
|
|
218
253
|
choices: answer.machines.map((candidate) => ({
|
|
@@ -303,7 +338,7 @@ async function withAuth(auth, work) {
|
|
|
303
338
|
url: auth.url,
|
|
304
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.',
|
|
305
340
|
});
|
|
306
|
-
|
|
341
|
+
ui.say(`\n${ui.note('Picking up where you left off.')}\n`);
|
|
307
342
|
return { auth: next, value: await work(next) };
|
|
308
343
|
}
|
|
309
344
|
}
|
|
@@ -451,14 +486,13 @@ async function weCreated(target) {
|
|
|
451
486
|
return backup === null;
|
|
452
487
|
}
|
|
453
488
|
async function installService() {
|
|
454
|
-
if (process.env.CRAFTSPACE_NO_SERVICE === '1')
|
|
455
|
-
process.stdout.write('Skipping the service. Run the beat yourself with: craftspace daemon\n');
|
|
489
|
+
if (process.env.CRAFTSPACE_NO_SERVICE === '1')
|
|
456
490
|
return;
|
|
457
|
-
}
|
|
458
491
|
if (process.platform === 'linux') {
|
|
459
492
|
const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
|
|
460
493
|
await mkdir(dir, { recursive: true });
|
|
461
494
|
await writeFile(path.join(dir, `${SERVICE_NAME}.service`), systemdUnit());
|
|
495
|
+
await run('loginctl', ['enable-linger', os.userInfo().username]).catch(() => undefined);
|
|
462
496
|
await run('systemctl', ['--user', 'daemon-reload']).catch(() => undefined);
|
|
463
497
|
await run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME]).catch(() => undefined);
|
|
464
498
|
return;
|
|
@@ -512,6 +546,16 @@ function launchdPlist() {
|
|
|
512
546
|
</plist>
|
|
513
547
|
`;
|
|
514
548
|
}
|
|
549
|
+
function here() {
|
|
550
|
+
return `${process.execPath} ${entryPath()}`;
|
|
551
|
+
}
|
|
552
|
+
async function serviceRunning() {
|
|
553
|
+
return (await run('systemctl', ['--user', 'is-active', SERVICE_NAME]).catch(() => null)) !== null;
|
|
554
|
+
}
|
|
555
|
+
async function userLingers() {
|
|
556
|
+
const shown = await run('loginctl', ['show-user', os.userInfo().username, '-p', 'Linger']).catch(() => null);
|
|
557
|
+
return shown?.stdout.includes('Linger=yes') === true;
|
|
558
|
+
}
|
|
515
559
|
function entryPath() {
|
|
516
560
|
return path.join(path.dirname(new URL(import.meta.url).pathname), 'index.js');
|
|
517
561
|
}
|
|
@@ -554,6 +598,9 @@ const MAX_BACKOFF_MS = 120_000;
|
|
|
554
598
|
const KEYS_BEGIN = '# craftspace begin';
|
|
555
599
|
const KEYS_END = '# craftspace end';
|
|
556
600
|
const POLL_INTERVAL_MS = 1_000;
|
|
601
|
+
const UPDATE_EVERY_MS = 6 * 60 * 60 * 1_000;
|
|
602
|
+
const BEATING = 'beating every 15s, outbound only, and keeps this set up';
|
|
603
|
+
const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
|
|
557
604
|
const DEFAULT_URL = process.env.CRAFTSPACE_URL ?? 'https://craftspace.app';
|
|
558
605
|
const PROBE_EVERY_MS = 300_000;
|
|
559
606
|
let probed = null;
|
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.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"
|