replicas-cli 0.3.284 → 0.3.285

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.
Files changed (2) hide show
  1. package/dist/index.cjs +181 -12
  2. package/package.json +4 -2
package/dist/index.cjs CHANGED
@@ -17208,7 +17208,7 @@ var init_workspace_identity = __esm({
17208
17208
  });
17209
17209
 
17210
17210
  // ../shared/src/routes/environment.ts
17211
- var ENVIRONMENT_MCP_TRANSPORTS, stringMap, createEnvironmentMcpRequestSchema, updateEnvironmentMcpRequestSchema, screenPasswordSchema, createEnvironmentDeviceRequestSchema, updateEnvironmentDeviceRequestSchema, provisionedDeviceSchema;
17211
+ var ENVIRONMENT_MCP_TRANSPORTS, stringMap, createEnvironmentMcpRequestSchema, updateEnvironmentMcpRequestSchema, screenPasswordSchema, hostSchema, createEnvironmentDeviceRequestSchema, updateEnvironmentDeviceRequestSchema, deviceConnectionResponseSchema, deviceHostKeysRequestSchema, DEVICE_CONNECT_COMMAND, provisionedDeviceSchema;
17212
17212
  var init_environment = __esm({
17213
17213
  "../shared/src/routes/environment.ts"() {
17214
17214
  "use strict";
@@ -17227,19 +17227,30 @@ var init_environment = __esm({
17227
17227
  });
17228
17228
  updateEnvironmentMcpRequestSchema = createEnvironmentMcpRequestSchema.partial();
17229
17229
  screenPasswordSchema = external_exports.string().min(1, "Enter the screen password").max(256).describe('Screen Sharing password, for `replicas computer screen`. On a Mac, the "VNC viewers may control screen with password" password. Never returned.');
17230
+ hostSchema = external_exports.string().max(253).regex(/^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|[0-9A-Fa-f:]+)$/, "Enter a hostname or IP address");
17230
17231
  createEnvironmentDeviceRequestSchema = external_exports.object({
17231
17232
  name: external_exports.string().regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/, "Use lowercase letters, numbers and dashes").describe("SSH alias in workspaces, e.g. `mac-mini` for `ssh mac-mini`."),
17232
- host: external_exports.string().max(253).regex(/^(?:[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?|[0-9A-Fa-f:]+)$/, "Enter a hostname or IP address").describe("Hostname or IP, such as a Tailscale MagicDNS name (`mac-mini`) or a public address."),
17233
- port: external_exports.number().int().min(1).max(65535).optional().describe("SSH port. Defaults to 22."),
17233
+ host: hostSchema.optional().describe("Hostname or IP, such as a Tailscale MagicDNS name (`mac-mini`) or a public address. Leave it out when the device connects to Replicas itself with `replicas device connect`."),
17234
+ port: external_exports.number().int().min(1).max(65535).optional().describe("SSH port on the device. Defaults to 22."),
17234
17235
  username: external_exports.string().regex(/^[A-Za-z0-9_][A-Za-z0-9._-]{0,63}$/, "Enter the account name on the device").describe("Account the agent signs in as."),
17235
17236
  screen_password: screenPasswordSchema.optional()
17236
17237
  });
17237
17238
  updateEnvironmentDeviceRequestSchema = createEnvironmentDeviceRequestSchema.partial().extend({
17239
+ host: external_exports.union([hostSchema, external_exports.literal("")]).optional().describe("New hostname or IP, or an empty string for a device that connects to Replicas itself."),
17238
17240
  screen_password: external_exports.union([screenPasswordSchema, external_exports.literal("")]).optional().describe("New Screen Sharing password, or an empty string to remove it.")
17239
17241
  });
17242
+ deviceConnectionResponseSchema = external_exports.object({
17243
+ name: external_exports.string(),
17244
+ port: external_exports.number(),
17245
+ workspace_ids: external_exports.array(external_exports.string())
17246
+ });
17247
+ deviceHostKeysRequestSchema = external_exports.object({
17248
+ host_keys: external_exports.array(external_exports.string().max(2048).regex(/^[a-z0-9@.-]+ [A-Za-z0-9+/]+={0,3}$/, "Each host key must be `<type> <base64>`")).min(1).max(8).transform((keys) => [...new Set(keys)].sort())
17249
+ });
17250
+ DEVICE_CONNECT_COMMAND = "npx -y replicas-cli device connect";
17240
17251
  provisionedDeviceSchema = external_exports.object({
17241
17252
  name: external_exports.string(),
17242
- host: external_exports.string(),
17253
+ host: external_exports.string().nullable(),
17243
17254
  port: external_exports.number(),
17244
17255
  username: external_exports.string(),
17245
17256
  private_key: external_exports.string(),
@@ -31704,7 +31715,7 @@ var CLI_VERSION;
31704
31715
  var init_cli_version = __esm({
31705
31716
  "../shared/src/cli-version.ts"() {
31706
31717
  "use strict";
31707
- CLI_VERSION = "0.3.284";
31718
+ CLI_VERSION = "0.3.285";
31708
31719
  }
31709
31720
  });
31710
31721
 
@@ -42288,6 +42299,29 @@ var init_variable_requests = __esm({
42288
42299
  }
42289
42300
  });
42290
42301
 
42302
+ // ../shared/src/websocket-liveness.ts
42303
+ function trackWebSocketLiveness(socket) {
42304
+ let answered = true;
42305
+ for (const event of ["pong", "message"]) socket.on(event, () => {
42306
+ answered = true;
42307
+ });
42308
+ return () => {
42309
+ if (socket.isPaused) {
42310
+ answered = true;
42311
+ return true;
42312
+ }
42313
+ if (!answered) return false;
42314
+ answered = false;
42315
+ socket.ping();
42316
+ return true;
42317
+ };
42318
+ }
42319
+ var init_websocket_liveness = __esm({
42320
+ "../shared/src/websocket-liveness.ts"() {
42321
+ "use strict";
42322
+ }
42323
+ });
42324
+
42291
42325
  // ../shared/src/index.ts
42292
42326
  var init_src = __esm({
42293
42327
  "../shared/src/index.ts"() {
@@ -42465,6 +42499,7 @@ var init_src = __esm({
42465
42499
  init_environment_setups();
42466
42500
  init_variable_requests();
42467
42501
  init_api_casing();
42502
+ init_websocket_liveness();
42468
42503
  }
42469
42504
  });
42470
42505
 
@@ -45428,16 +45463,24 @@ async function envDevicesListCommand(env) {
45428
45463
  `/v1/environments/${await resolveEnvironmentId(env)}/devices`
45429
45464
  );
45430
45465
  output(response, ({ environment_devices }) => {
45431
- for (const device of environment_devices) console.log(`${device.id} ${device.name} ${device.username}@${device.host}:${device.port}`);
45466
+ for (const device of environment_devices) {
45467
+ console.log(`${device.id} ${device.name} ${device.host === null ? `${device.username}, connects from the device` : `${device.username}@${device.host}:${device.port}`}`);
45468
+ }
45432
45469
  });
45433
45470
  }
45434
45471
  async function envDevicesAddCommand(env, name, options) {
45435
45472
  const id = await resolveEnvironmentId(env);
45436
45473
  const body = { name, host: options.host, username: options.user, port: options.port, screen_password: options.screenPassword };
45437
45474
  const response = await orgAuthenticatedFetch(`/v1/environments/${id}/devices`, { method: "POST", body });
45438
- output(response, ({ environment_device }) => {
45475
+ const connect2 = options.host === void 0 ? await orgAuthenticatedFetch(`/v1/environments/${id}/devices/${response.environment_device.id}/connect-token`) : void 0;
45476
+ output({ ...response, connect_token: connect2?.token }, ({ environment_device, connect_token }) => {
45439
45477
  console.log(import_chalk20.default.green(`Added device ${name}. Allow its key by running this on the device as ${options.user}:`));
45440
45478
  console.log(`mkdir -p ~/.ssh && echo '${environment_device.public_key}' >> ~/.ssh/authorized_keys`);
45479
+ if (connect_token) {
45480
+ console.log(import_chalk20.default.green("Then keep this running on it, so workspaces can reach it, and paste this token when it asks:"));
45481
+ console.log(DEVICE_CONNECT_COMMAND);
45482
+ console.log(connect_token);
45483
+ }
45441
45484
  });
45442
45485
  }
45443
45486
  async function envDevicesRemoveCommand(env, nameOrId) {
@@ -45448,6 +45491,18 @@ async function envDevicesRemoveCommand(env, nameOrId) {
45448
45491
  await orgAuthenticatedFetch(`/v1/environments/${id}/devices/${device.id}`, { method: "DELETE" });
45449
45492
  outputSuccess(`Removed device ${device.name}`);
45450
45493
  }
45494
+ async function envDevicesResetCommand(env, nameOrId) {
45495
+ const id = await resolveEnvironmentId(env);
45496
+ const { environment_devices } = await orgAuthenticatedFetch(`/v1/environments/${id}/devices`);
45497
+ const device = resolveByNameOrId(nameOrId, environment_devices);
45498
+ if (!device) throw new Error(`Device not found: ${nameOrId}`);
45499
+ const response = await orgAuthenticatedFetch(`/v1/environments/${id}/devices/${device.id}/connect-token`, { method: "POST" });
45500
+ output(response, ({ token }) => {
45501
+ console.log(import_chalk20.default.green(`Reset ${device.name}'s connection. Its old token no longer works; run this on it again and paste the new token when it asks:`));
45502
+ console.log(DEVICE_CONNECT_COMMAND);
45503
+ console.log(token);
45504
+ });
45505
+ }
45451
45506
  function envWarmPoolCommand(enabled) {
45452
45507
  return async (env) => {
45453
45508
  const id = await resolveEnvironmentId(env);
@@ -48592,6 +48647,116 @@ trim "$1"`;
48592
48647
  }
48593
48648
  });
48594
48649
 
48650
+ // src/commands/device.ts
48651
+ async function refuse(response) {
48652
+ const body = await response.json().catch(() => null);
48653
+ throw new Error(body && typeof body === "object" && "error" in body ? String(body.error) : `Replicas refused this device (${response.status})`);
48654
+ }
48655
+ async function scanHostKeys(port) {
48656
+ const { stdout } = await (0, import_node_util.promisify)(import_node_child_process4.execFile)("ssh-keyscan", ["-T", "5", "-p", String(port), "127.0.0.1"]).catch(() => ({ stdout: "" }));
48657
+ return stdout.split("\n").filter((line) => line && !line.startsWith("#")).map((line) => line.split(" ").slice(1, 3).join(" "));
48658
+ }
48659
+ async function deviceConnectCommand() {
48660
+ const token = process.env.REPLICAS_DEVICE_TOKEN ?? (isInteractive() ? (await ask({ type: "password", name: "token", message: "Device token, from the device in the environment's Devices settings" })).token : void 0);
48661
+ if (!token) throw new Error("Paste the device token when asked, or set REPLICAS_DEVICE_TOKEN to it. It's on the device in the environment's Devices settings.");
48662
+ const headers = { Authorization: `Bearer ${token}` };
48663
+ let pinned = false;
48664
+ const waiting = /* @__PURE__ */ new Map();
48665
+ let port = 22;
48666
+ let summary = "";
48667
+ const wait = (workspaceId, failures = 0) => {
48668
+ if (!waiting.has(workspaceId)) return;
48669
+ const startedAt = Date.now();
48670
+ let opened = false;
48671
+ const socket = new import_ws.default(`${MONOLITH_URL.replace(/^http/, "ws")}/v1/devices/workspaces/${workspaceId}/ws`, { headers, handshakeTimeout: HEARTBEAT_MS });
48672
+ waiting.set(workspaceId, socket);
48673
+ const alive = trackWebSocketLiveness(socket);
48674
+ let heartbeat;
48675
+ socket.once("open", () => {
48676
+ heartbeat = setInterval(() => {
48677
+ if (!alive()) socket.terminate();
48678
+ }, HEARTBEAT_MS);
48679
+ });
48680
+ socket.once("message", (data, isBinary) => {
48681
+ if (isBinary || data.toString() !== "open") return socket.close();
48682
+ opened = true;
48683
+ wait(workspaceId);
48684
+ const sshd = (0, import_node_net.connect)(port, "127.0.0.1");
48685
+ const stream = (0, import_ws.createWebSocketStream)(socket);
48686
+ const close = () => {
48687
+ sshd.destroy();
48688
+ stream.destroy();
48689
+ };
48690
+ sshd.once("error", (error51) => {
48691
+ console.log(import_chalk32.default.yellow(`A workspace couldn't reach SSH on port ${port} here (${error51.message}). ${REMOTE_LOGIN_HINT}`));
48692
+ });
48693
+ sshd.on("error", close).on("close", close);
48694
+ stream.on("error", close).on("close", close);
48695
+ sshd.pipe(stream).pipe(sshd);
48696
+ });
48697
+ socket.on("error", () => void 0);
48698
+ socket.once("close", () => {
48699
+ clearInterval(heartbeat);
48700
+ if (opened) return;
48701
+ const failed = Date.now() - startedAt < POLL_INTERVAL_MS;
48702
+ const delay = failed ? Math.min(5e3 * 2 ** failures, MAX_RETRY_DELAY_MS) : 0;
48703
+ setTimeout(() => {
48704
+ if (waiting.get(workspaceId) === socket) wait(workspaceId, failed ? failures + 1 : 0);
48705
+ }, delay).unref();
48706
+ });
48707
+ };
48708
+ for (; ; ) {
48709
+ const response = await fetch(`${MONOLITH_URL}/v1/devices/workspaces`, { headers }).catch(() => null);
48710
+ if (response && [401, 403, 409].includes(response.status)) await refuse(response);
48711
+ const device = response?.ok ? deviceConnectionResponseSchema.parse(await response.json()) : void 0;
48712
+ let problem = device ? void 0 : `Can't reach Replicas (${response?.status ?? "network error"}), retrying...`;
48713
+ if (device && !pinned) {
48714
+ const body = { host_keys: await scanHostKeys(device.port) };
48715
+ const pin = body.host_keys.length === 0 ? void 0 : await fetch(`${MONOLITH_URL}/v1/devices/host-keys`, { method: "PUT", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => null);
48716
+ if (pin && [400, 401, 403, 409].includes(pin.status)) await refuse(pin);
48717
+ pinned = pin?.ok === true;
48718
+ if (pin === void 0) problem = `Can't reach SSH on port ${device.port} here, retrying... ${REMOTE_LOGIN_HINT}`;
48719
+ else if (!pinned) problem = `Can't reach Replicas (${pin?.status ?? "network error"}), retrying...`;
48720
+ }
48721
+ if (device && pinned) {
48722
+ port = device.port;
48723
+ for (const [workspaceId, socket] of waiting) {
48724
+ if (device.workspace_ids.includes(workspaceId)) continue;
48725
+ waiting.delete(workspaceId);
48726
+ socket?.close();
48727
+ }
48728
+ for (const workspaceId of device.workspace_ids) {
48729
+ if (waiting.has(workspaceId)) continue;
48730
+ waiting.set(workspaceId, void 0);
48731
+ wait(workspaceId);
48732
+ }
48733
+ }
48734
+ const next = problem ?? `\u2713 ${device?.name}: ready for ${waiting.size} ${waiting.size === 1 ? "workspace" : "workspaces"}, forwarding to SSH on port ${port}`;
48735
+ if (next !== summary) console.log(problem ? import_chalk32.default.yellow(next) : import_chalk32.default.green(next));
48736
+ summary = next;
48737
+ await (0, import_promises2.setTimeout)(POLL_INTERVAL_MS);
48738
+ }
48739
+ }
48740
+ var import_chalk32, import_node_child_process4, import_node_net, import_promises2, import_node_util, import_ws, POLL_INTERVAL_MS, HEARTBEAT_MS, MAX_RETRY_DELAY_MS, REMOTE_LOGIN_HINT;
48741
+ var init_device = __esm({
48742
+ "src/commands/device.ts"() {
48743
+ "use strict";
48744
+ import_chalk32 = __toESM(require("chalk"));
48745
+ import_node_child_process4 = require("child_process");
48746
+ import_node_net = require("net");
48747
+ import_promises2 = require("timers/promises");
48748
+ import_node_util = require("util");
48749
+ import_ws = __toESM(require("ws"));
48750
+ init_src();
48751
+ init_monolith_url();
48752
+ init_output();
48753
+ POLL_INTERVAL_MS = 1e4;
48754
+ HEARTBEAT_MS = 3e4;
48755
+ MAX_RETRY_DELAY_MS = 5 * 6e4;
48756
+ REMOTE_LOGIN_HINT = "On a Mac, turn on Remote Login in System Settings \u2192 General \u2192 Sharing.";
48757
+ }
48758
+ });
48759
+
48595
48760
  // src/index.ts
48596
48761
  var src_exports = {};
48597
48762
  function parseBooleanOption(value) {
@@ -48663,6 +48828,7 @@ var init_src2 = __esm({
48663
48828
  init_slack4();
48664
48829
  init_service();
48665
48830
  init_environment3();
48831
+ init_device();
48666
48832
  init_config();
48667
48833
  init_organization2();
48668
48834
  init_output();
@@ -48680,6 +48846,7 @@ var init_src2 = __esm({
48680
48846
  "codex-auth",
48681
48847
  "muse-auth",
48682
48848
  "config",
48849
+ "device",
48683
48850
  "init",
48684
48851
  "login",
48685
48852
  "logout",
@@ -48858,12 +49025,14 @@ var init_src2 = __esm({
48858
49025
  envMcps.command("remove <env> <name-or-id>").description("Remove an MCP server").action(run(envMcpsRemoveCommand));
48859
49026
  envDevices = environment.command("devices", { hidden: true }).description("Computers the agent reaches over SSH, like a Mac mini or your own laptop");
48860
49027
  envDevices.command("list <env>").description("List devices").action(run(envDevicesListCommand));
48861
- envDevices.command("add <env> <name>").description("Add a device and print the command that allows its key on the device").requiredOption("--host <host>", "Hostname or IP, such as its Tailscale name").requiredOption("--user <user>", "Account the agent signs in as").option("--port <port>", "SSH port (default 22)", (value) => {
49028
+ envDevices.command("add <env> <name>").description("Add a device and print the command that allows its key on the device").option("--host <host>", "Hostname or IP, such as its Tailscale name. Leave it out to connect from the device instead").requiredOption("--user <user>", "Account the agent signs in as").option("--port <port>", "SSH port (default 22)", (value) => {
48862
49029
  const port = Number(value);
48863
49030
  if (!Number.isInteger(port)) throw new import_commander.InvalidArgumentError("must be a whole number");
48864
49031
  return port;
48865
49032
  }).option("--screen-password <password>", "Screen Sharing password, so the agent can see and control the screen").action(run(envDevicesAddCommand));
48866
49033
  envDevices.command("remove <env> <name-or-id>").description("Remove a device and delete its key").action(run(envDevicesRemoveCommand));
49034
+ envDevices.command("reset <env> <name-or-id>").description("Replace the connect token of a device that connects from itself and forget its SSH identity, for a leaked token or a reinstalled device").action(run(envDevicesResetCommand));
49035
+ program.command("device", { hidden: true }).description("Run on one of your computers so Replicas workspaces can reach it").command("connect").description("Keep this computer reachable from the workspaces that have it, without opening ports or a tailnet. Asks for the token from the device's settings, or reads REPLICAS_DEVICE_TOKEN").action(run(deviceConnectCommand));
48867
49036
  envWarmPool = environment.command("warm-pool").description("Keep pre-warmed workspaces ready for an environment");
48868
49037
  envWarmPool.command("enable <env>").description("Enable the warm pool").action(run(envWarmPoolCommand(true)));
48869
49038
  envWarmPool.command("disable <env>").description("Disable the warm pool").action(run(envWarmPoolCommand(false)));
@@ -48975,12 +49144,12 @@ ${VISUAL_PAGE_RULES}`).action(run(visualizeCommand));
48975
49144
  });
48976
49145
 
48977
49146
  // src/bin.ts
48978
- var import_node_child_process4 = require("child_process");
48979
- var import_chalk32 = __toESM(require("chalk"));
49147
+ var import_node_child_process5 = require("child_process");
49148
+ var import_chalk33 = __toESM(require("chalk"));
48980
49149
  init_config();
48981
49150
  if (process.argv[2] === "computer" && isAgentMode()) {
48982
- const result = (0, import_node_child_process4.spawnSync)("replicas-computer", process.argv.slice(3), { stdio: "inherit" });
48983
- if (result.error) console.error(import_chalk32.default.red(`
49151
+ const result = (0, import_node_child_process5.spawnSync)("replicas-computer", process.argv.slice(3), { stdio: "inherit" });
49152
+ if (result.error) console.error(import_chalk33.default.red(`
48984
49153
  \u2717 replicas-computer is unavailable in this workspace image
48985
49154
  `));
48986
49155
  process.exitCode = result.error ? 1 : result.status ?? 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "replicas-cli",
3
- "version": "0.3.284",
3
+ "version": "0.3.285",
4
4
  "description": "CLI for managing Replicas workspaces - SSH into cloud dev environments with automatic port forwarding",
5
5
  "main": "dist/index.cjs",
6
6
  "bin": {
@@ -47,11 +47,13 @@
47
47
  "dotenv": "^17.2.3",
48
48
  "open": "^8.4.2",
49
49
  "prompts": "^2.4.2",
50
- "tslib": "^2.6.2"
50
+ "tslib": "^2.6.2",
51
+ "ws": "8.21.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@replicas/shared": "*",
54
55
  "@types/node": "^20.11.0",
56
+ "@types/ws": "8.18.1",
55
57
  "tsup": "^8.5.0",
56
58
  "typescript": "^5.3.3"
57
59
  }