@craftspace/cli 0.3.0 → 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 CHANGED
@@ -20250,7 +20250,7 @@ var MemberActivityListResponse = external_exports.object({
20250
20250
  });
20251
20251
 
20252
20252
  // dist/machine.js
20253
- import { execFile as execFile4 } from "node:child_process";
20253
+ import { execFile as execFile4, spawn as spawn2 } from "node:child_process";
20254
20254
  import { mkdir, readFile as readFile2, readdir, rm, writeFile } from "node:fs/promises";
20255
20255
  import os5 from "node:os";
20256
20256
  import path4 from "node:path";
@@ -23112,10 +23112,13 @@ var machine = {
23112
23112
  };
23113
23113
  return answer.machine;
23114
23114
  },
23115
+ async url() {
23116
+ return (await readConfig())?.url ?? null;
23117
+ },
23115
23118
  async signIn({ url: url2, why, install = false }) {
23116
23119
  const where = url2 ?? (await readConfig())?.url ?? DEFAULT_URL;
23117
23120
  if (!process.stdin.isTTY)
23118
- throw new Error(`${why} Run: craftspace login --token <token>`);
23121
+ throw new Error(`${why} Run: cs login --token <token>`);
23119
23122
  const page = `${where}/workstations`;
23120
23123
  ui.say(`
23121
23124
  ${ui.attention(why)}
@@ -23193,21 +23196,12 @@ ${ui.ok(`Signed in as ${signed.ownerName}. This machine is ${ui.name(signed.name
23193
23196
  await new Promise((resolve) => setTimeout(resolve, nextDelayMs(failures, busy)));
23194
23197
  }
23195
23198
  },
23196
- async update() {
23197
- ui.say(ui.waiting(`checking ${CLI_PACKAGE}`));
23198
- const moved = await update.latest();
23199
- if (moved === null) {
23200
- ui.say(`${ui.clear()}${ui.ok(`craftspace ${ui.name(CLI_VERSION)}, nothing newer on npm`)}`);
23201
- return;
23202
- }
23203
- ui.say(`${ui.clear()}${ui.waiting(`installing ${moved}`)}`);
23204
- const done = await update.toLatest();
23205
- ui.say(`${ui.clear()}${done === null ? ui.bad(`could not install ${moved}`, `run it yourself: ${CLI_INSTALL}@latest`) : ui.ok(`craftspace ${ui.name(CLI_VERSION)} -> ${ui.name(done)}`)}`);
23206
- },
23207
- async run({ argv, on }) {
23208
- if (on === void 0)
23209
- return runHere(argv);
23210
- return runThere({ argv, on });
23199
+ async run() {
23200
+ const first = await signedInOrAsk();
23201
+ const { auth, value: answer } = await withAuth(first, (live) => call({ ...live, method: "GET", path: "/api/machines", schema: MachinesResponseSchema }));
23202
+ if (answer.machines.length === 0)
23203
+ return nowhereToGo(auth);
23204
+ return openTerminal(await pick2(answer.machines));
23211
23205
  },
23212
23206
  async daemon() {
23213
23207
  if (process.env.CRAFTSPACE_NO_SERVICE === "1")
@@ -23235,122 +23229,61 @@ async function requireSignedIn() {
23235
23229
  const config2 = await readConfig();
23236
23230
  const token = await readToken();
23237
23231
  if (!config2 || !token)
23238
- throw new Error("This machine is not signed in. Run: craftspace login --token <token>");
23232
+ throw new Error("This machine is not signed in. Run: cs login --token <token>");
23239
23233
  return { url: config2.url, token };
23240
23234
  }
23241
- async function runHere(argv) {
23242
- const id = `local-${Date.now().toString(36)}`;
23243
- await writeSession({ id, state: "working", title: argv.join(" ").slice(0, 200) });
23244
- try {
23245
- return await runner.attended(argv);
23246
- } finally {
23247
- await clearSession(id);
23248
- }
23249
- }
23250
- async function runThere({ argv, on }) {
23251
- const first = await signedInOrAsk();
23252
- const { auth, value: target } = await withAuth(first, (live) => machineNamed({ ...live, name: on }));
23253
- const { url: url2, token } = auth;
23254
- let run5 = await call({
23255
- url: url2,
23256
- token,
23257
- method: "POST",
23258
- path: `/api/machines/${target.id}/runs`,
23259
- body: { argv },
23260
- schema: MachineRunSchema
23235
+ async function pick2(machines) {
23236
+ return dist_default6({
23237
+ message: "Which workstation?",
23238
+ choices: machines.map((candidate) => ({
23239
+ name: `${candidate.name.padEnd(24)} ${candidate.state.padEnd(12)} ${specs(candidate)} ${beat(candidate)}`,
23240
+ value: candidate,
23241
+ description: candidate.sshAddress === null ? "No address yet, so nothing to open" : void 0
23242
+ })),
23243
+ pageSize: 12
23261
23244
  });
23262
- ui.say(`${ui.name(target.name)} ${ui.dim(`\xB7 queued ${run5.id}`)}
23263
-
23264
- `);
23265
- let printed = 0;
23266
- for (; ; ) {
23267
- if (run5.output.length > printed) {
23268
- process.stdout.write(run5.output.slice(printed));
23269
- printed = run5.output.length;
23270
- }
23271
- if (run5.state === "done")
23272
- return 0;
23273
- if (run5.state === "failed")
23274
- return run5.exitCode ?? 1;
23275
- if (run5.state === "needs_input") {
23276
- ui.say(`
23277
- ${ui.attention(`${target.name} is asking:`)} ${run5.question ?? "it needs a decision"}
23278
- > `);
23279
- const reply = await readStdinLine();
23280
- if (reply === null)
23281
- return 130;
23282
- run5 = await call({
23283
- url: url2,
23284
- token,
23285
- method: "POST",
23286
- path: `/api/machines/runs/${run5.id}/answer`,
23287
- body: { text: reply },
23288
- schema: MachineRunSchema
23289
- });
23290
- printed = run5.output.length;
23291
- continue;
23292
- }
23293
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
23294
- run5 = await readRun({ url: url2, token, machineId: target.id, runId: run5.id });
23295
- }
23296
23245
  }
23297
- async function readRun({ url: url2, token, machineId, runId }) {
23298
- const answer = await call({
23299
- url: url2,
23300
- token,
23301
- method: "GET",
23302
- path: `/api/machines/${machineId}/runs`,
23303
- schema: MachineRunsResponseSchema
23304
- });
23305
- const found = answer.runs.find((candidate) => candidate.id === runId);
23306
- if (found === void 0)
23307
- throw new Error(`Run ${runId} is gone.`);
23308
- return found;
23246
+ function specs({ platform, cores, memoryGb }) {
23247
+ return [platform, cores === null ? null : `${cores} vCPU`, memoryGb === null ? null : `${memoryGb} GB`].filter((part) => part !== null).join(" \xB7 ");
23309
23248
  }
23310
- async function machineNamed({ url: url2, token, name }) {
23311
- const answer = await call({ url: url2, token, method: "GET", path: "/api/machines", schema: MachinesResponseSchema });
23312
- const found = answer.machines.find((candidate) => candidate.name === name || candidate.id === name);
23313
- if (found !== void 0)
23314
- return found;
23315
- if (answer.machines.length === 0)
23316
- throw new Error("This team has no workstations yet. Add one at /workstations.");
23317
- if (!process.stdin.isTTY) {
23318
- throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(", ")}`);
23249
+ function beat({ lastBeatAt }) {
23250
+ if (lastBeatAt === null)
23251
+ return "never beat";
23252
+ const seconds = Math.max(0, Math.round((Date.now() - new Date(lastBeatAt).getTime()) / 1e3));
23253
+ if (seconds < 60)
23254
+ return `beat ${seconds}s ago`;
23255
+ if (seconds < 3600)
23256
+ return `beat ${Math.round(seconds / 60)}m ago`;
23257
+ return `beat ${Math.round(seconds / 3600)}h ago`;
23258
+ }
23259
+ async function openTerminal(target) {
23260
+ if (target.sshAddress === null) {
23261
+ throw new Error(`${target.name} has not told us an address yet. It reports one on its next beat.`);
23319
23262
  }
23263
+ const where = `${target.sshUser ?? "root"}@${target.sshAddress}`;
23320
23264
  ui.say(`
23321
- ${ui.attention(`No workstation called ${name}.`)}
23265
+ ${ui.dim("\u2192")} ${ui.name(target.name)} ${ui.dim(`\xB7 ${where}`)}
23322
23266
 
23323
23267
  `);
23324
- return dist_default6({
23325
- message: "Run it on",
23326
- choices: answer.machines.map((candidate) => ({
23327
- name: `${candidate.name.padEnd(20)} ${candidate.state}`,
23328
- value: candidate
23329
- }))
23330
- });
23331
- }
23332
- function readStdinLine() {
23333
23268
  return new Promise((resolve) => {
23334
- let buffer = "";
23335
- process.stdin.setEncoding("utf8");
23336
- const done = (line) => {
23337
- process.stdin.off("data", onData);
23338
- process.stdin.off("end", onEnd);
23339
- process.stdin.pause();
23340
- resolve(line.trim() === "" ? null : line.trim());
23341
- };
23342
- const onData = (chunk) => {
23343
- buffer += chunk;
23344
- const at = buffer.indexOf("\n");
23345
- if (at !== -1)
23346
- done(buffer.slice(0, at));
23347
- };
23348
- const onEnd = () => done(buffer);
23349
- process.stdin.on("data", onData);
23350
- process.stdin.on("end", onEnd);
23351
- process.stdin.resume();
23269
+ const session = spawn2("ssh", ["-t", where], { stdio: "inherit" });
23270
+ session.on("error", (error51) => {
23271
+ ui.say(ui.bad(`could not start ssh`, error51.message));
23272
+ resolve(1);
23273
+ });
23274
+ session.on("exit", (code) => resolve(code ?? 0));
23352
23275
  });
23353
23276
  }
23277
+ function nowhereToGo({ url: url2 }) {
23278
+ ui.say(`
23279
+ ${ui.attention("No workstations here yet.")}
23280
+
23281
+ `);
23282
+ ui.say(ui.field("Add one", ui.link(`${url2}/workstations`)));
23283
+ ui.say(ui.field("Or sign in", "a Mac or Linux box with: cs login"));
23284
+ ui.say("\n");
23285
+ return 1;
23286
+ }
23354
23287
  async function tools() {
23355
23288
  if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
23356
23289
  return probed.tools;
@@ -23475,14 +23408,6 @@ ${KEYS_END}
23475
23408
  function trimEnd(text) {
23476
23409
  return text.replace(/\s+$/, "");
23477
23410
  }
23478
- async function writeSession(session) {
23479
- const dir = path4.join(machine.home(), "sessions");
23480
- await mkdir(dir, { recursive: true, mode: 448 });
23481
- await writeFile(path4.join(dir, `${session.id}.json`), JSON.stringify(session), { mode: 384 });
23482
- }
23483
- async function clearSession(id) {
23484
- await rm(path4.join(machine.home(), "sessions", `${id}.json`), { force: true });
23485
- }
23486
23411
  async function writeAgentConfigs({ url: url2, token, writes }) {
23487
23412
  const planned = writes.length > 0 ? writes : defaultWrites({ url: url2, token });
23488
23413
  const owned = [];
@@ -23676,7 +23601,6 @@ var MAX_REPORTED_SESSIONS = 50;
23676
23601
  var MAX_BACKOFF_MS = 12e4;
23677
23602
  var KEYS_BEGIN = "# craftspace begin";
23678
23603
  var KEYS_END = "# craftspace end";
23679
- var POLL_INTERVAL_MS = 1e3;
23680
23604
  var UPDATE_EVERY_MS = 6 * 60 * 60 * 1e3;
23681
23605
  var BEATING = "beating every 15s, outbound only, and keeps this set up";
23682
23606
  var LINGER_LINE = `sudo loginctl enable-linger ${os5.userInfo().username}`;
@@ -23689,9 +23613,9 @@ var running = /* @__PURE__ */ new Set();
23689
23613
  // dist/index.js
23690
23614
  setup.widenPath();
23691
23615
  var program2 = new Command();
23692
- program2.enablePositionalOptions().name("craftspace").description("Sign this machine in to your team, so every agent on it reads the same brain.").version(CLI_VERSION).showHelpAfterError();
23693
- program2.command("login").description("sign this machine in").option("--token <token>", "the login token from your team", process.env.CRAFTSPACE_TOKEN).option("--url <url>", "your control plane", process.env.CRAFTSPACE_URL ?? "https://craftspace.app").option("--no-install", "do not install anything this machine is missing").action(async ({ token, url: url2, install }) => {
23694
- const where = url2.replace(/\/$/, "");
23616
+ program2.enablePositionalOptions().name("cs").description("Sign this machine in to your team, so every agent on it reads the same brain.").version(CLI_VERSION).showHelpAfterError();
23617
+ program2.command("login").description("sign this machine in").option("--token <token>", "the login token from your team", process.env.CRAFTSPACE_TOKEN).option("--url <url>", "your control plane, remembered after the first login").option("--no-install", "do not install anything this machine is missing").action(async ({ token, url: url2, install }) => {
23618
+ const where = (url2 ?? process.env.CRAFTSPACE_URL ?? await machine.url() ?? DEFAULT_URL2).replace(/\/$/, "");
23695
23619
  const signed = token === void 0 ? (await machine.signIn({ url: where, why: "Signing this machine in.", install })).machine : await machine.login({ token, url: where, install });
23696
23620
  const daemon = await machine.daemon();
23697
23621
  ui.say(ui.heading(`Signed in as ${signed.ownerName}`));
@@ -23702,8 +23626,8 @@ program2.command("login").description("sign this machine in").option("--token <t
23702
23626
  ${ui.note(`Listed at ${ui.link(`${where}/workstations`)}`)}
23703
23627
  `);
23704
23628
  });
23705
- program2.command("run").description("run a command here, or on another workstation with --on").argument("<command...>", "the command line to run").option("--on <machine>", "run it on that workstation instead of this one").allowUnknownOption().passThroughOptions().action(async (command, { on }) => {
23706
- process.exitCode = await machine.run({ argv: command, ...on === void 0 ? {} : { on } });
23629
+ program2.command("run").description("pick a workstation and open a terminal on it").action(async () => {
23630
+ process.exitCode = await machine.run();
23707
23631
  });
23708
23632
  program2.command("status").description("what this machine is and what it is doing").action(async () => {
23709
23633
  const { machine: found } = await machine.status();
@@ -23716,9 +23640,6 @@ program2.command("status").description("what this machine is and what it is doin
23716
23640
  ui.say(ui.field("Session", `${session.state} ${session.title ?? session.id}`));
23717
23641
  ui.say("\n");
23718
23642
  });
23719
- program2.command("update").description("move this machine to the latest craftspace CLI").action(async () => {
23720
- await machine.update();
23721
- });
23722
23643
  program2.command("logout").description("leave the team, and undo what login wrote").action(async () => {
23723
23644
  await machine.logout();
23724
23645
  ui.say(ui.ok("Left the team. Nothing of ours is running here."));
@@ -23733,6 +23654,7 @@ try {
23733
23654
  function machineSpecs({ platform, cores, memoryGb }) {
23734
23655
  return [platform, cores === null ? null : `${cores} vCPU`, memoryGb == null ? null : `${memoryGb} GB`].filter((part) => part !== null).join(" \xB7 ");
23735
23656
  }
23657
+ var DEFAULT_URL2 = "https://craftspace.app";
23736
23658
  function secondsSince(at) {
23737
23659
  return Math.round((Date.now() - new Date(at).getTime()) / 1e3);
23738
23660
  }
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,7 @@ export declare const machine: {
17
18
  }>;
18
19
  beatOnce(): Promise<boolean>;
19
20
  beatForever(): Promise<void>;
20
- update(): Promise<void>;
21
- run({ argv, on }: {
22
- argv: string[];
23
- on?: string;
24
- }): Promise<number>;
21
+ run(): Promise<number>;
25
22
  daemon(): Promise<{
26
23
  text: string;
27
24
  ok: boolean;
package/dist/machine.js CHANGED
@@ -1,9 +1,9 @@
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_INSTALL, CLI_PACKAGE, CLI_VERSION, MACHINE_BEAT_BUSY_INTERVAL_MS, MACHINE_BEAT_INTERVAL_MS, MachineBeatResponseSchema, MachineLoginResponseSchema, MachineRunSchema, MachineRunsResponseSchema, MachineSchema, MachineSessionSchema, MachinesResponseSchema, } from '@craftspace/shared';
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';
@@ -43,10 +43,13 @@ export const machine = {
43
43
  };
44
44
  return answer.machine;
45
45
  },
46
+ async url() {
47
+ return (await readConfig())?.url ?? null;
48
+ },
46
49
  async signIn({ url, why, install = false }) {
47
50
  const where = url ?? (await readConfig())?.url ?? DEFAULT_URL;
48
51
  if (!process.stdin.isTTY)
49
- throw new Error(`${why} Run: craftspace login --token <token>`);
52
+ throw new Error(`${why} Run: cs login --token <token>`);
50
53
  const page = `${where}/workstations`;
51
54
  ui.say(`\n${ui.attention(why)}\n\n`);
52
55
  if (await confirm({ message: `Open ${page} for a key?`, default: true })) {
@@ -124,23 +127,12 @@ export const machine = {
124
127
  await new Promise((resolve) => setTimeout(resolve, nextDelayMs(failures, busy)));
125
128
  }
126
129
  },
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
- },
140
- async run({ argv, on }) {
141
- if (on === undefined)
142
- return runHere(argv);
143
- return runThere({ argv, on });
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));
144
136
  },
145
137
  async daemon() {
146
138
  if (process.env.CRAFTSPACE_NO_SERVICE === '1')
@@ -168,116 +160,57 @@ async function requireSignedIn() {
168
160
  const config = await readConfig();
169
161
  const token = await readToken();
170
162
  if (!config || !token)
171
- throw new Error('This machine is not signed in. Run: craftspace login --token <token>');
163
+ throw new Error('This machine is not signed in. Run: cs login --token <token>');
172
164
  return { url: config.url, token };
173
165
  }
174
- async function runHere(argv) {
175
- const id = `local-${Date.now().toString(36)}`;
176
- await writeSession({ id, state: 'working', title: argv.join(' ').slice(0, 200) });
177
- try {
178
- return await runner.attended(argv);
179
- }
180
- finally {
181
- await clearSession(id);
182
- }
183
- }
184
- async function runThere({ argv, on }) {
185
- const first = await signedInOrAsk();
186
- const { auth, value: target } = await withAuth(first, (live) => machineNamed({ ...live, name: on }));
187
- const { url, token } = auth;
188
- let run = await call({
189
- url,
190
- token,
191
- method: 'POST',
192
- path: `/api/machines/${target.id}/runs`,
193
- body: { argv },
194
- schema: MachineRunSchema,
195
- });
196
- ui.say(`${ui.name(target.name)} ${ui.dim(`· queued ${run.id}`)}\n\n`);
197
- let printed = 0;
198
- for (;;) {
199
- if (run.output.length > printed) {
200
- process.stdout.write(run.output.slice(printed));
201
- printed = run.output.length;
202
- }
203
- if (run.state === 'done')
204
- return 0;
205
- if (run.state === 'failed')
206
- return run.exitCode ?? 1;
207
- if (run.state === 'needs_input') {
208
- ui.say(`\n${ui.attention(`${target.name} is asking:`)} ${run.question ?? 'it needs a decision'}\n> `);
209
- const reply = await readStdinLine();
210
- if (reply === null)
211
- return 130;
212
- run = await call({
213
- url,
214
- token,
215
- method: 'POST',
216
- path: `/api/machines/runs/${run.id}/answer`,
217
- body: { text: reply },
218
- schema: MachineRunSchema,
219
- });
220
- printed = run.output.length;
221
- continue;
222
- }
223
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
224
- run = await readRun({ url, token, machineId: target.id, runId: run.id });
225
- }
226
- }
227
- async function readRun({ url, token, machineId, runId, }) {
228
- const answer = await call({
229
- url,
230
- token,
231
- method: 'GET',
232
- path: `/api/machines/${machineId}/runs`,
233
- schema: MachineRunsResponseSchema,
234
- });
235
- const found = answer.runs.find((candidate) => candidate.id === runId);
236
- if (found === undefined)
237
- throw new Error(`Run ${runId} is gone.`);
238
- return found;
239
- }
240
- async function machineNamed({ url, token, name, }) {
241
- const answer = await call({ url, token, method: 'GET', path: '/api/machines', schema: MachinesResponseSchema });
242
- const found = answer.machines.find((candidate) => candidate.name === name || candidate.id === name);
243
- if (found !== undefined)
244
- return found;
245
- if (answer.machines.length === 0)
246
- throw new Error('This team has no workstations yet. Add one at /workstations.');
247
- if (!process.stdin.isTTY) {
248
- throw new Error(`No workstation called ${name}. This team has: ${answer.machines.map((m) => m.name).join(', ')}`);
249
- }
250
- ui.say(`\n${ui.attention(`No workstation called ${name}.`)}\n\n`);
166
+ async function pick(machines) {
251
167
  return select({
252
- message: 'Run it on',
253
- choices: answer.machines.map((candidate) => ({
254
- name: `${candidate.name.padEnd(20)} ${candidate.state}`,
168
+ message: 'Which workstation?',
169
+ choices: machines.map((candidate) => ({
170
+ name: `${candidate.name.padEnd(24)} ${candidate.state.padEnd(12)} ${specs(candidate)} ${beat(candidate)}`,
255
171
  value: candidate,
172
+ description: candidate.sshAddress === null ? 'No address yet, so nothing to open' : undefined,
256
173
  })),
174
+ pageSize: 12,
257
175
  });
258
176
  }
259
- function readStdinLine() {
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`);
260
198
  return new Promise((resolve) => {
261
- let buffer = '';
262
- process.stdin.setEncoding('utf8');
263
- const done = (line) => {
264
- process.stdin.off('data', onData);
265
- process.stdin.off('end', onEnd);
266
- process.stdin.pause();
267
- resolve(line.trim() === '' ? null : line.trim());
268
- };
269
- const onData = (chunk) => {
270
- buffer += chunk;
271
- const at = buffer.indexOf('\n');
272
- if (at !== -1)
273
- done(buffer.slice(0, at));
274
- };
275
- const onEnd = () => done(buffer);
276
- process.stdin.on('data', onData);
277
- process.stdin.on('end', onEnd);
278
- 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));
279
205
  });
280
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
+ }
281
214
  async function tools() {
282
215
  if (probed !== null && Date.now() - probed.at < PROBE_EVERY_MS)
283
216
  return probed.tools;
@@ -398,14 +331,6 @@ async function writeAuthorizedKeys(keys) {
398
331
  function trimEnd(text) {
399
332
  return text.replace(/\s+$/, '');
400
333
  }
401
- async function writeSession(session) {
402
- const dir = path.join(machine.home(), 'sessions');
403
- await mkdir(dir, { recursive: true, mode: 0o700 });
404
- await writeFile(path.join(dir, `${session.id}.json`), JSON.stringify(session), { mode: 0o600 });
405
- }
406
- async function clearSession(id) {
407
- await rm(path.join(machine.home(), 'sessions', `${id}.json`), { force: true });
408
- }
409
334
  async function writeAgentConfigs({ url, token, writes, }) {
410
335
  const planned = writes.length > 0 ? writes : defaultWrites({ url, token });
411
336
  const owned = [];
@@ -597,7 +522,6 @@ const MAX_REPORTED_SESSIONS = 50;
597
522
  const MAX_BACKOFF_MS = 120_000;
598
523
  const KEYS_BEGIN = '# craftspace begin';
599
524
  const KEYS_END = '# craftspace end';
600
- const POLL_INTERVAL_MS = 1_000;
601
525
  const UPDATE_EVERY_MS = 6 * 60 * 60 * 1_000;
602
526
  const BEATING = 'beating every 15s, outbound only, and keeps this set up';
603
527
  const LINGER_LINE = `sudo loginctl enable-linger ${os.userInfo().username}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craftspace/cli",
3
- "version": "0.3.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": [