@craftspace/cli 0.1.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.
@@ -0,0 +1,21 @@
1
+ import { type Machine } from '@craftspace/shared';
2
+ export declare const machine: {
3
+ home(): string;
4
+ login({ token, url, install }: {
5
+ token: string;
6
+ url: string;
7
+ install: boolean;
8
+ }): Promise<Machine>;
9
+ status(): Promise<{
10
+ machine: Machine;
11
+ url: string;
12
+ } | null>;
13
+ beatOnce(): Promise<boolean>;
14
+ beatForever(): Promise<void>;
15
+ run({ argv, on }: {
16
+ argv: string[];
17
+ on?: string;
18
+ }): Promise<number>;
19
+ logout(): Promise<void>;
20
+ };
21
+ export declare function nextDelayMs(failures: number, busy?: boolean): number;
@@ -0,0 +1,472 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
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';
7
+ import { z } from 'zod';
8
+ import { runner } from './run.js';
9
+ import { setup } from './setup.js';
10
+ const run = promisify(execFile);
11
+ export const machine = {
12
+ home() {
13
+ return process.env.CRAFTSPACE_CLI_HOME ?? path.join(os.homedir(), '.craftspace');
14
+ },
15
+ async login({ token, url, install }) {
16
+ const answer = await call({
17
+ url,
18
+ token,
19
+ method: 'POST',
20
+ path: '/api/machines/login',
21
+ body: {
22
+ name: os.hostname().split('.')[0] ?? 'machine',
23
+ platform: `${process.platform}-${process.arch}`,
24
+ sshUser: os.userInfo().username,
25
+ cores: os.cpus().length,
26
+ memoryGb: Math.max(1, Math.round(os.totalmem() / 1024 ** 3)),
27
+ cli: CLI_VERSION,
28
+ publicKey: await ensureKey(),
29
+ },
30
+ schema: MachineLoginResponseSchema,
31
+ });
32
+ await mkdir(machine.home(), { recursive: true, mode: 0o700 });
33
+ await writeFile(configPath(), JSON.stringify({ url, machineId: answer.machine.id }, null, 2), { mode: 0o600 });
34
+ await writeFile(tokenPath(), token, { mode: 0o600 });
35
+ await writeAgentConfigs({ url, token, writes: answer.write });
36
+ await installService();
37
+ probed = {
38
+ at: Date.now(),
39
+ tools: await setup.run({ install, rewire, say: (line) => process.stdout.write(`${line}\n`) }),
40
+ };
41
+ return answer.machine;
42
+ },
43
+ 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 };
52
+ },
53
+ async beatOnce() {
54
+ const { url, token } = await requireSignedIn();
55
+ const answer = await call({
56
+ url,
57
+ token,
58
+ method: 'POST',
59
+ path: '/api/machines/beat',
60
+ body: {
61
+ cli: CLI_VERSION,
62
+ ...load(),
63
+ sessions: await readSessions(),
64
+ runs: drainReports(),
65
+ tools: await tools(),
66
+ sshUser: os.userInfo().username,
67
+ },
68
+ schema: MachineBeatResponseSchema,
69
+ });
70
+ await writeAuthorizedKeys(answer.authorizedKeys);
71
+ for (const work of answer.work) {
72
+ if (running.has(work.id))
73
+ continue;
74
+ running.add(work.id);
75
+ void runner
76
+ .unattended({ work, onProgress: report })
77
+ .then(report)
78
+ .catch((error) => report({ id: work.id, state: 'failed', output: messageOf(error) }))
79
+ .finally(() => running.delete(work.id));
80
+ }
81
+ return running.size > 0 || answer.work.length > 0;
82
+ },
83
+ async beatForever() {
84
+ let failures = 0;
85
+ let busy = false;
86
+ for (;;) {
87
+ try {
88
+ busy = await machine.beatOnce();
89
+ failures = 0;
90
+ }
91
+ catch (error) {
92
+ failures += 1;
93
+ process.stderr.write(`beat failed: ${messageOf(error)}\n`);
94
+ }
95
+ await new Promise((resolve) => setTimeout(resolve, nextDelayMs(failures, busy)));
96
+ }
97
+ },
98
+ async run({ argv, on }) {
99
+ if (on === undefined)
100
+ return runHere(argv);
101
+ return runThere({ argv, on });
102
+ },
103
+ async logout() {
104
+ const config = await readConfig();
105
+ const token = await readToken();
106
+ if (config && token) {
107
+ await call({ url: config.url, token, method: 'POST', path: '/api/machines/logout', schema: z.object({}) }).catch(() => undefined);
108
+ }
109
+ await removeAgentConfigs();
110
+ await uninstallService();
111
+ await rm(machine.home(), { recursive: true, force: true });
112
+ },
113
+ };
114
+ async function requireSignedIn() {
115
+ const config = await readConfig();
116
+ const token = await readToken();
117
+ if (!config || !token)
118
+ throw new Error('This machine is not signed in. Run: craftspace login --token <token>');
119
+ return { url: config.url, token };
120
+ }
121
+ async function runHere(argv) {
122
+ const id = `local-${Date.now().toString(36)}`;
123
+ await writeSession({ id, state: 'working', title: argv.join(' ').slice(0, 200) });
124
+ try {
125
+ return await runner.attended(argv);
126
+ }
127
+ finally {
128
+ await clearSession(id);
129
+ }
130
+ }
131
+ async function runThere({ argv, on }) {
132
+ const { url, token } = await requireSignedIn();
133
+ const target = await machineNamed({ url, token, name: on });
134
+ let run = await call({
135
+ url,
136
+ token,
137
+ method: 'POST',
138
+ path: `/api/machines/${target.id}/runs`,
139
+ body: { argv },
140
+ schema: MachineRunSchema,
141
+ });
142
+ process.stdout.write(`${target.name} · queued ${run.id}\n\n`);
143
+ let printed = 0;
144
+ for (;;) {
145
+ if (run.output.length > printed) {
146
+ process.stdout.write(run.output.slice(printed));
147
+ printed = run.output.length;
148
+ }
149
+ if (run.state === 'done')
150
+ return 0;
151
+ if (run.state === 'failed')
152
+ return run.exitCode ?? 1;
153
+ if (run.state === 'needs_input') {
154
+ process.stdout.write(`\n${target.name} is asking: ${run.question ?? 'it needs a decision'}\n> `);
155
+ const reply = await readStdinLine();
156
+ if (reply === null)
157
+ return 130;
158
+ run = await call({
159
+ url,
160
+ token,
161
+ method: 'POST',
162
+ path: `/api/machines/runs/${run.id}/answer`,
163
+ body: { text: reply },
164
+ schema: MachineRunSchema,
165
+ });
166
+ printed = run.output.length;
167
+ continue;
168
+ }
169
+ await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
170
+ run = await readRun({ url, token, machineId: target.id, runId: run.id });
171
+ }
172
+ }
173
+ async function readRun({ url, token, machineId, runId, }) {
174
+ const answer = await call({
175
+ url,
176
+ token,
177
+ method: 'GET',
178
+ path: `/api/machines/${machineId}/runs`,
179
+ schema: MachineRunsResponseSchema,
180
+ });
181
+ const found = answer.runs.find((candidate) => candidate.id === runId);
182
+ if (found === undefined)
183
+ throw new Error(`Run ${runId} is gone.`);
184
+ return found;
185
+ }
186
+ async function machineNamed({ url, token, name, }) {
187
+ const answer = await call({ url, token, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema });
188
+ const found = answer.machines.find((candidate) => candidate.name === name || candidate.id === name);
189
+ if (found === undefined) {
190
+ throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
191
+ }
192
+ return found;
193
+ }
194
+ function readStdinLine() {
195
+ return new Promise((resolve) => {
196
+ let buffer = '';
197
+ process.stdin.setEncoding('utf8');
198
+ const done = (line) => {
199
+ process.stdin.off('data', onData);
200
+ process.stdin.off('end', onEnd);
201
+ process.stdin.pause();
202
+ resolve(line.trim() === '' ? null : line.trim());
203
+ };
204
+ const onData = (chunk) => {
205
+ buffer += chunk;
206
+ const at = buffer.indexOf('\n');
207
+ if (at !== -1)
208
+ done(buffer.slice(0, at));
209
+ };
210
+ const onEnd = () => done(buffer);
211
+ process.stdin.on('data', onData);
212
+ process.stdin.on('end', onEnd);
213
+ process.stdin.resume();
214
+ });
215
+ }
216
+ async function tools() {
217
+ if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
218
+ return probed.tools;
219
+ const found = await setup.run({ install: process.env.CRAFTSPACE_NO_INSTALL !== '1', rewire });
220
+ probed = { at: Date.now(), tools: found };
221
+ return found;
222
+ }
223
+ async function rewire() {
224
+ const { url, token } = await requireSignedIn();
225
+ await writeAgentConfigs({ url, token, writes: [] });
226
+ }
227
+ function report(next) {
228
+ reports.set(next.id, next);
229
+ }
230
+ function drainReports() {
231
+ const pending = [...reports.values()];
232
+ for (const item of pending)
233
+ if (item.state !== 'running')
234
+ reports.delete(item.id);
235
+ return pending;
236
+ }
237
+ async function call({ url, token, method, path: route, body, schema, }) {
238
+ const response = await fetch(`${url}${route}`, {
239
+ method,
240
+ headers: {
241
+ authorization: `Bearer ${token}`,
242
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
243
+ },
244
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
245
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
246
+ });
247
+ if (!response.ok)
248
+ throw new Error(`${route} answered ${response.status}`);
249
+ return schema.parse(await response.json());
250
+ }
251
+ async function ensureKey() {
252
+ const priv = path.join(machine.home(), 'id_ed25519');
253
+ const pub = `${priv}.pub`;
254
+ const existing = await readFile(pub, 'utf8').catch(() => null);
255
+ if (existing !== null)
256
+ return existing.trim();
257
+ await mkdir(machine.home(), { recursive: true, mode: 0o700 });
258
+ await run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-q', '-f', priv, '-C', `craftspace-${os.hostname()}`]);
259
+ return (await readFile(pub, 'utf8')).trim();
260
+ }
261
+ async function readConfig() {
262
+ const raw = await readFile(configPath(), 'utf8').catch(() => null);
263
+ if (raw === null)
264
+ return null;
265
+ return ConfigSchema.parse(JSON.parse(raw));
266
+ }
267
+ async function readToken() {
268
+ const raw = await readFile(tokenPath(), 'utf8').catch(() => null);
269
+ return raw === null ? null : raw.trim();
270
+ }
271
+ async function readSessions() {
272
+ const dir = path.join(machine.home(), 'sessions');
273
+ const names = await readdir(dir).catch(() => []);
274
+ const sessions = [];
275
+ for (const name of names.filter((entry) => entry.endsWith('.json'))) {
276
+ const raw = await readFile(path.join(dir, name), 'utf8').catch(() => null);
277
+ if (raw === null)
278
+ continue;
279
+ const parsed = MachineSessionSchema.safeParse(JSON.parse(raw));
280
+ if (parsed.success)
281
+ sessions.push(parsed.data);
282
+ }
283
+ return sessions.slice(0, MAX_REPORTED_SESSIONS);
284
+ }
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
+ async function writeAuthorizedKeys(keys) {
288
+ const target = path.join(os.homedir(), '.ssh', 'authorized_keys');
289
+ const current = await readFile(target, 'utf8').catch(() => '');
290
+ const before = current.split(KEYS_BEGIN)[0] ?? '';
291
+ const after = current.includes(KEYS_END) ? (current.split(KEYS_END)[1] ?? '') : '';
292
+ const block = keys.length === 0 ? '' : `${KEYS_BEGIN}\n${keys.join('\n')}\n${KEYS_END}\n`;
293
+ const next = `${trimEnd(before)}${trimEnd(before) === '' ? '' : '\n'}${block}${after.replace(/^\n/, '')}`;
294
+ if (next === current)
295
+ return;
296
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
297
+ await writeFile(target, next, { mode: 0o600 });
298
+ }
299
+ function trimEnd(text) {
300
+ return text.replace(/\s+$/, '');
301
+ }
302
+ async function writeSession(session) {
303
+ const dir = path.join(machine.home(), 'sessions');
304
+ await mkdir(dir, { recursive: true, mode: 0o700 });
305
+ await writeFile(path.join(dir, `${session.id}.json`), JSON.stringify(session), { mode: 0o600 });
306
+ }
307
+ async function clearSession(id) {
308
+ await rm(path.join(machine.home(), 'sessions', `${id}.json`), { force: true });
309
+ }
310
+ async function writeAgentConfigs({ url, token, writes, }) {
311
+ const planned = writes.length > 0 ? writes : defaultWrites({ url, token });
312
+ const owned = [];
313
+ for (const write of planned) {
314
+ const target = expand(write.path);
315
+ await mkdir(path.dirname(target), { recursive: true });
316
+ const current = await readFile(target, 'utf8').catch(() => null);
317
+ if (current !== null)
318
+ await writeFile(`${target}.craftspace-backup`, current, { flag: 'wx' }).catch(() => undefined);
319
+ const merged = mergeInto(current === null ? {} : JSON.parse(current), write.contents);
320
+ await writeFile(target, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
321
+ owned.push(target);
322
+ }
323
+ await writeFile(ownedPath(), JSON.stringify({ paths: owned }, null, 2));
324
+ }
325
+ function mergeInto(current, incoming) {
326
+ const merged = { ...current };
327
+ for (const [key, value] of Object.entries(incoming)) {
328
+ const existing = merged[key];
329
+ merged[key] =
330
+ isPlainObject(existing) && isPlainObject(value) ? mergeInto(existing, value) : value;
331
+ }
332
+ return merged;
333
+ }
334
+ function isPlainObject(value) {
335
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
336
+ }
337
+ function defaultWrites({ url, token }) {
338
+ const server = { craftspace: { type: 'http', url: `${url}/mcp`, headers: { Authorization: `Bearer ${token}` } } };
339
+ return [{ path: '~/.mcp.json', contents: { mcpServers: server } }];
340
+ }
341
+ async function removeAgentConfigs() {
342
+ const raw = await readFile(ownedPath(), 'utf8').catch(() => null);
343
+ if (raw === null)
344
+ return;
345
+ const owned = OwnedSchema.parse(JSON.parse(raw));
346
+ for (const target of owned.paths) {
347
+ const current = await readFile(target, 'utf8').catch(() => null);
348
+ if (current === null)
349
+ continue;
350
+ const parsed = JSON.parse(current);
351
+ const servers = parsed.mcpServers;
352
+ if (isPlainObject(servers))
353
+ delete servers.craftspace;
354
+ if (await weCreated(target)) {
355
+ await rm(target, { force: true });
356
+ continue;
357
+ }
358
+ await writeFile(target, `${JSON.stringify(parsed, null, 2)}\n`);
359
+ }
360
+ }
361
+ async function weCreated(target) {
362
+ const backup = await readFile(`${target}.craftspace-backup`, 'utf8').catch(() => null);
363
+ return backup === null;
364
+ }
365
+ async function installService() {
366
+ if (process.env.CRAFTSPACE_NO_SERVICE === '1') {
367
+ process.stdout.write('Skipping the service. Run the beat yourself with: craftspace daemon\n');
368
+ return;
369
+ }
370
+ if (process.platform === 'linux') {
371
+ const dir = path.join(os.homedir(), '.config', 'systemd', 'user');
372
+ await mkdir(dir, { recursive: true });
373
+ await writeFile(path.join(dir, `${SERVICE_NAME}.service`), systemdUnit());
374
+ await run('systemctl', ['--user', 'daemon-reload']).catch(() => undefined);
375
+ await run('systemctl', ['--user', 'enable', '--now', SERVICE_NAME]).catch(() => undefined);
376
+ return;
377
+ }
378
+ if (process.platform === 'darwin') {
379
+ const target = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCH_LABEL}.plist`);
380
+ await mkdir(path.dirname(target), { recursive: true });
381
+ await writeFile(target, launchdPlist());
382
+ await run('launchctl', ['unload', target]).catch(() => undefined);
383
+ await run('launchctl', ['load', target]).catch(() => undefined);
384
+ }
385
+ }
386
+ async function uninstallService() {
387
+ if (process.platform === 'linux') {
388
+ await run('systemctl', ['--user', 'disable', '--now', SERVICE_NAME]).catch(() => undefined);
389
+ await rm(path.join(os.homedir(), '.config', 'systemd', 'user', `${SERVICE_NAME}.service`), { force: true });
390
+ return;
391
+ }
392
+ if (process.platform === 'darwin') {
393
+ const target = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LAUNCH_LABEL}.plist`);
394
+ await run('launchctl', ['unload', target]).catch(() => undefined);
395
+ await rm(target, { force: true });
396
+ }
397
+ }
398
+ function systemdUnit() {
399
+ return [
400
+ '[Unit]',
401
+ 'Description=Craftspace machine',
402
+ '',
403
+ '[Service]',
404
+ `ExecStart=${process.execPath} ${entryPath()} daemon`,
405
+ 'Restart=always',
406
+ 'RestartSec=5',
407
+ '',
408
+ '[Install]',
409
+ 'WantedBy=default.target',
410
+ '',
411
+ ].join('\n');
412
+ }
413
+ function launchdPlist() {
414
+ return `<?xml version="1.0" encoding="UTF-8"?>
415
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
416
+ <plist version="1.0">
417
+ <dict>
418
+ <key>Label</key><string>${LAUNCH_LABEL}</string>
419
+ <key>ProgramArguments</key>
420
+ <array><string>${process.execPath}</string><string>${entryPath()}</string><string>daemon</string></array>
421
+ <key>RunAtLoad</key><true/>
422
+ <key>KeepAlive</key><true/>
423
+ </dict>
424
+ </plist>
425
+ `;
426
+ }
427
+ function entryPath() {
428
+ return path.join(path.dirname(new URL(import.meta.url).pathname), 'index.js');
429
+ }
430
+ function expand(target) {
431
+ return target.startsWith('~/') ? path.join(os.homedir(), target.slice(2)) : target;
432
+ }
433
+ function configPath() {
434
+ return path.join(machine.home(), 'config.json');
435
+ }
436
+ function tokenPath() {
437
+ return path.join(machine.home(), 'token');
438
+ }
439
+ function ownedPath() {
440
+ return path.join(machine.home(), 'owned.json');
441
+ }
442
+ function load() {
443
+ const cores = Math.max(1, os.cpus().length);
444
+ const busy = Math.min(100, ((os.loadavg()[0] ?? 0) / cores) * 100);
445
+ const used = ((os.totalmem() - os.freemem()) / os.totalmem()) * 100;
446
+ return { cpuPercent: Math.round(busy), memoryPercent: Math.round(used) };
447
+ }
448
+ export function nextDelayMs(failures, busy = false) {
449
+ if (failures > 0) {
450
+ const backoff = Math.min(MAX_BACKOFF_MS, MACHINE_BEAT_INTERVAL_MS * 2 ** failures);
451
+ return Math.round(backoff * (0.85 + Math.random() * 0.3));
452
+ }
453
+ const base = busy ? MACHINE_BEAT_BUSY_INTERVAL_MS : MACHINE_BEAT_INTERVAL_MS;
454
+ return Math.round(base * (0.85 + Math.random() * 0.3));
455
+ }
456
+ function messageOf(error) {
457
+ return error instanceof Error ? error.message : String(error);
458
+ }
459
+ const ConfigSchema = z.object({ url: z.string(), machineId: z.string() });
460
+ const OwnedSchema = z.object({ paths: z.array(z.string()) });
461
+ const SERVICE_NAME = 'craftspace';
462
+ const LAUNCH_LABEL = 'app.craftspace.machine';
463
+ const REQUEST_TIMEOUT_MS = 15_000;
464
+ const MAX_REPORTED_SESSIONS = 50;
465
+ const MAX_BACKOFF_MS = 120_000;
466
+ const KEYS_BEGIN = '# craftspace begin';
467
+ const KEYS_END = '# craftspace end';
468
+ const POLL_INTERVAL_MS = 1_000;
469
+ const PROBE_EVERY_MS = 300_000;
470
+ let probed = null;
471
+ const reports = new Map();
472
+ const running = new Set();
@@ -0,0 +1,7 @@
1
+ import type { MachineTool } from '@craftspace/shared';
2
+ export declare const probe: {
3
+ tools({ mcpPath }?: {
4
+ mcpPath?: string;
5
+ }): Promise<MachineTool[]>;
6
+ };
7
+ export declare function githubUser(hostsYml: string): string | null;
package/dist/probe.js ADDED
@@ -0,0 +1,102 @@
1
+ import { execFile } from 'node:child_process';
2
+ import net from 'node:net';
3
+ import { access, readFile } from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { promisify } from 'node:util';
7
+ const run = promisify(execFile);
8
+ export const probe = {
9
+ async tools({ mcpPath = path.join(os.homedir(), '.mcp.json') } = {}) {
10
+ const [brain, claude, codex, git, chrome, ssh] = await Promise.all([
11
+ brainWired(mcpPath),
12
+ version({ id: 'claude', command: 'claude' }),
13
+ version({ id: 'codex', command: 'codex' }),
14
+ gitAndGithub(),
15
+ chromeInstalled(),
16
+ sshListening(),
17
+ ]);
18
+ return [brain, claude, codex, git, chrome, ssh];
19
+ },
20
+ };
21
+ async function brainWired(mcpPath) {
22
+ const raw = await readFile(mcpPath, 'utf8').catch(() => null);
23
+ if (raw === null)
24
+ return { id: 'brain', ok: false, detail: `no ${short(mcpPath)} on this machine` };
25
+ const parsed = JSON.parse(raw);
26
+ const servers = parsed?.mcpServers;
27
+ const wired = servers !== undefined && servers !== null && 'craftspace' in servers;
28
+ return {
29
+ id: 'brain',
30
+ ok: wired,
31
+ detail: wired ? short(mcpPath) : `${short(mcpPath)} has no craftspace entry`,
32
+ };
33
+ }
34
+ async function version({ id, command }) {
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' };
39
+ }
40
+ async function gitAndGithub() {
41
+ const git = await firstLine('git', ['--version']);
42
+ if (typeof git !== 'string')
43
+ return { id: 'git', ok: false, detail: 'git is not installed' };
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` };
47
+ const account = await githubAccount();
48
+ return {
49
+ id: 'git',
50
+ ok: account !== null,
51
+ detail: account === null
52
+ ? `${git} · ${gh}, run gh auth login on this machine`
53
+ : `${git} · ${gh}, signed in as ${account}`,
54
+ };
55
+ }
56
+ async function githubAccount() {
57
+ const raw = await readFile(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8').catch(() => null);
58
+ return raw === null ? null : githubUser(raw);
59
+ }
60
+ export function githubUser(hostsYml) {
61
+ return /^ {4}user: *(\S+) *$/m.exec(hostsYml)?.[1] ?? null;
62
+ }
63
+ async function chromeInstalled() {
64
+ for (const command of [...MAC_CHROME, ...LINUX_CHROME]) {
65
+ const found = await firstLine(command, ['--version']);
66
+ if (typeof found === 'string')
67
+ return { id: 'chrome', ok: true, detail: found };
68
+ }
69
+ return { id: 'chrome', ok: false, detail: 'not installed yet, so browser runs will fail' };
70
+ }
71
+ function sshListening() {
72
+ return new Promise((resolve) => {
73
+ const socket = net.connect({ host: '127.0.0.1', port: 22 });
74
+ const answer = (ok, detail) => {
75
+ socket.destroy();
76
+ resolve({ id: 'ssh', ok, detail });
77
+ };
78
+ socket.setTimeout(SSH_PROBE_TIMEOUT_MS);
79
+ socket.on('connect', () => answer(true, 'sshd is listening on port 22'));
80
+ socket.on('timeout', () => answer(false, 'nothing answered on port 22'));
81
+ socket.on('error', () => answer(false, 'sshd is not running, so nobody can ssh in'));
82
+ });
83
+ }
84
+ async function firstLine(command, args) {
85
+ if (command.includes('/')) {
86
+ const reachable = await access(command).then(() => true, () => false);
87
+ if (!reachable)
88
+ return 'absent';
89
+ }
90
+ const answer = await run(command, args, { timeout: PROBE_TIMEOUT_MS }).catch((thrown) => thrown);
91
+ if (answer instanceof Error)
92
+ return answer.code === 'ENOENT' ? 'absent' : 'broken';
93
+ const line = answer.stdout.split('\n')[0]?.trim() ?? '';
94
+ return line === '' ? 'broken' : line.slice(0, 200);
95
+ }
96
+ function short(target) {
97
+ return target.startsWith(os.homedir()) ? `~${target.slice(os.homedir().length)}` : target;
98
+ }
99
+ const MAC_CHROME = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
100
+ const LINUX_CHROME = ['google-chrome', 'google-chrome-stable', 'chromium'];
101
+ const PROBE_TIMEOUT_MS = 5_000;
102
+ const SSH_PROBE_TIMEOUT_MS = 1_500;
package/dist/run.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { type MachineRunReport, type MachineWork } from '@craftspace/shared';
2
+ export declare const runner: {
3
+ attended(argv: string[]): Promise<number>;
4
+ unattended({ work, onProgress, }: {
5
+ work: MachineWork;
6
+ onProgress?: (report: MachineRunReport) => void;
7
+ }): Promise<MachineRunReport>;
8
+ };
9
+ export declare function headlessArgv(work: MachineWork): string[];