@craftspace/cli 0.3.0 → 0.4.1

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
@@ -18969,54 +18969,54 @@ var AUDIT_GROUPS = [
18969
18969
  ];
18970
18970
  var AUDIT_LISTED_ACTIONS = AUDIT_GROUPS.flatMap((group) => group.actions);
18971
18971
  var AUDIT_DEFAULT_ACTIONS = AUDIT_LISTED_ACTIONS.filter((action) => AUDIT_TIERS[action] !== AuditTier.Volume);
18972
- var AUDIT_VERBS = {
18973
- [AuditAction.AccessDenied]: "Was refused",
18972
+ var AUDIT_LABELS = {
18973
+ [AuditAction.AccessDenied]: "Denied request",
18974
18974
  [AuditAction.MemberSignedIn]: "Signed in",
18975
- [AuditAction.MemberInvited]: "Invited",
18975
+ [AuditAction.MemberInvited]: "Invited member",
18976
18976
  [AuditAction.MemberInviteAccepted]: "Joined the organization",
18977
- [AuditAction.MemberRoleChanged]: "Changed the role of",
18978
- [AuditAction.MemberRemoved]: "Removed",
18977
+ [AuditAction.MemberRoleChanged]: "Changed member role",
18978
+ [AuditAction.MemberRemoved]: "Removed member",
18979
18979
  [AuditAction.TokenMinted]: "Minted member token",
18980
18980
  [AuditAction.TokenRevoked]: "Revoked member token",
18981
- [AuditAction.AdminEnteredOrg]: "Opened support view on",
18982
- [AuditAction.OrganizationRenamed]: "Renamed the organization to",
18983
- [AuditAction.OrganizationWebsiteChanged]: "Set the organization website to",
18984
- [AuditAction.OrganizationModelChanged]: "Set the default model to",
18981
+ [AuditAction.AdminEnteredOrg]: "Opened support view",
18982
+ [AuditAction.OrganizationRenamed]: "Renamed the organization",
18983
+ [AuditAction.OrganizationWebsiteChanged]: "Changed the organization website",
18984
+ [AuditAction.OrganizationModelChanged]: "Changed the default model",
18985
18985
  [AuditAction.OrganizationDeleted]: "Deleted the organization",
18986
- [AuditAction.ConnectionCreated]: "Connected",
18987
- [AuditAction.ConnectionReconnected]: "Reconnected",
18988
- [AuditAction.ConnectionDeleted]: "Disconnected",
18989
- [AuditAction.ConnectionAccessChanged]: "Changed who can use",
18986
+ [AuditAction.ConnectionCreated]: "Connected app",
18987
+ [AuditAction.ConnectionReconnected]: "Reconnected app",
18988
+ [AuditAction.ConnectionDeleted]: "Disconnected app",
18989
+ [AuditAction.ConnectionAccessChanged]: "Changed who can use app",
18990
18990
  [AuditAction.McpConnectionCreated]: "Attached MCP server",
18991
18991
  [AuditAction.McpConnectionDeleted]: "Detached MCP server",
18992
- [AuditAction.McpConnectionToolsChanged]: "Changed the tools agents may call on",
18993
- [AuditAction.PageCreated]: "Created",
18994
- [AuditAction.PageUpdated]: "Wrote",
18995
- [AuditAction.PageDeleted]: "Deleted",
18996
- [AuditAction.PageMoved]: "Moved",
18997
- [AuditAction.PageShared]: "Published a public link for",
18998
- [AuditAction.PageUnshared]: "Revoked the public link for",
18992
+ [AuditAction.McpConnectionToolsChanged]: "Changed MCP server tools",
18993
+ [AuditAction.PageCreated]: "Created page",
18994
+ [AuditAction.PageUpdated]: "Updated page",
18995
+ [AuditAction.PageDeleted]: "Deleted page",
18996
+ [AuditAction.PageMoved]: "Moved page",
18997
+ [AuditAction.PageShared]: "Published a public link for page",
18998
+ [AuditAction.PageUnshared]: "Revoked the public link for page",
18999
18999
  [AuditAction.SpaceCreated]: "Created space",
19000
- [AuditAction.SpaceRenamed]: "Renamed space to",
19000
+ [AuditAction.SpaceRenamed]: "Renamed space",
19001
19001
  [AuditAction.SpaceDeleted]: "Deleted space",
19002
19002
  [AuditAction.SpaceAccessChanged]: "Changed who can reach space",
19003
19003
  [AuditAction.TableCreated]: "Created table",
19004
19004
  [AuditAction.TableDeleted]: "Deleted table",
19005
19005
  [AuditAction.AgentCreated]: "Created agent",
19006
19006
  [AuditAction.AgentDeleted]: "Deleted agent",
19007
- [AuditAction.AgentGrantsChanged]: "Changed what pages are reachable by",
19008
- [AuditAction.ChannelConnected]: "Connected",
19009
- [AuditAction.ChannelDisconnected]: "Disconnected",
19007
+ [AuditAction.AgentGrantsChanged]: "Changed what agent can reach",
19008
+ [AuditAction.ChannelConnected]: "Connected channel",
19009
+ [AuditAction.ChannelDisconnected]: "Disconnected channel",
19010
19010
  [AuditAction.KitInstalled]: "Installed kit",
19011
19011
  [AuditAction.KitRemoved]: "Removed kit",
19012
19012
  [AuditAction.WorkflowCreated]: "Created workflow",
19013
19013
  [AuditAction.WorkflowPublished]: "Published workflow",
19014
19014
  [AuditAction.WorkflowDeleted]: "Deleted workflow",
19015
- [AuditAction.WorkflowSecretRotated]: "Rotated the webhook secret of",
19016
- [AuditAction.McpRequest]: "Called the MCP endpoint",
19017
- [AuditAction.McpToolCall]: "Called tool",
19018
- [AuditAction.PageRead]: "Read",
19019
- [AuditAction.PageWrite]: "Wrote"
19015
+ [AuditAction.WorkflowSecretRotated]: "Rotated workflow webhook secret",
19016
+ [AuditAction.McpRequest]: "Called MCP method",
19017
+ [AuditAction.McpToolCall]: "Called MCP tool",
19018
+ [AuditAction.PageRead]: "Read page",
19019
+ [AuditAction.PageWrite]: "Wrote page"
19020
19020
  };
19021
19021
  var AuditTargetSchema = external_exports.object({
19022
19022
  type: external_exports.string(),
@@ -19782,7 +19782,7 @@ var SaveWorkstationBodySchema = external_exports.object({
19782
19782
  var WorkstationResponseSchema = external_exports.object({ workstation: WorkstationSchema });
19783
19783
 
19784
19784
  // ../../shared/dist/machines/machine.js
19785
- var CLI_VERSION = "0.3.0";
19785
+ var CLI_VERSION = "0.4.1";
19786
19786
  var CLI_PACKAGE = "@craftspace/cli";
19787
19787
  var CLI_INSTALL = `npm i -g ${CLI_PACKAGE}`;
19788
19788
  var MACHINE_BEAT_INTERVAL_MS = 15e3;
@@ -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.1",
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": [