@tonbo/cli 0.13.0 → 0.14.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.
Files changed (2) hide show
  1. package/dist/bin/tonbo.js +854 -8
  2. package/package.json +1 -1
package/dist/bin/tonbo.js CHANGED
@@ -359,6 +359,17 @@ var managementOperations = {
359
359
  "idempotencyKey": false,
360
360
  "cli": "tonbo workspace list"
361
361
  },
362
+ "listProjects": {
363
+ "method": "GET",
364
+ "path": "/projects",
365
+ "pathParams": [],
366
+ "query": [
367
+ "origin",
368
+ "cursor",
369
+ "limit"
370
+ ],
371
+ "idempotencyKey": false
372
+ },
362
373
  "listProjectBindings": {
363
374
  "method": "GET",
364
375
  "path": "/projects/bindings",
@@ -804,6 +815,101 @@ var managementOperations = {
804
815
  "idempotencyKey": false,
805
816
  "cli": "tonbo machine shapes"
806
817
  },
818
+ "execMachineCommand": {
819
+ "method": "POST",
820
+ "path": "/machines/{name}/exec",
821
+ "pathParams": [
822
+ "name"
823
+ ],
824
+ "query": [],
825
+ "idempotencyKey": true,
826
+ "cli": "tonbo machine exec",
827
+ "cell": true
828
+ },
829
+ "readMachineFile": {
830
+ "method": "GET",
831
+ "path": "/machines/{name}/files/content",
832
+ "pathParams": [
833
+ "name"
834
+ ],
835
+ "query": [
836
+ "path"
837
+ ],
838
+ "idempotencyKey": false,
839
+ "cli": "tonbo machine file read",
840
+ "cell": true
841
+ },
842
+ "writeMachineFile": {
843
+ "method": "PUT",
844
+ "path": "/machines/{name}/files/content",
845
+ "pathParams": [
846
+ "name"
847
+ ],
848
+ "query": [
849
+ "path"
850
+ ],
851
+ "idempotencyKey": true,
852
+ "cli": "tonbo machine file write",
853
+ "cell": true
854
+ },
855
+ "deleteMachineFile": {
856
+ "method": "DELETE",
857
+ "path": "/machines/{name}/files/content",
858
+ "pathParams": [
859
+ "name"
860
+ ],
861
+ "query": [
862
+ "path",
863
+ "recursive"
864
+ ],
865
+ "idempotencyKey": true,
866
+ "cli": "tonbo machine file delete",
867
+ "cell": true
868
+ },
869
+ "statMachineFile": {
870
+ "method": "GET",
871
+ "path": "/machines/{name}/files/stat",
872
+ "pathParams": [
873
+ "name"
874
+ ],
875
+ "query": [
876
+ "path"
877
+ ],
878
+ "idempotencyKey": false,
879
+ "cli": "tonbo machine file stat",
880
+ "cell": true
881
+ },
882
+ "listMachineFiles": {
883
+ "method": "GET",
884
+ "path": "/machines/{name}/files/list",
885
+ "pathParams": [
886
+ "name"
887
+ ],
888
+ "query": [
889
+ "path"
890
+ ],
891
+ "idempotencyKey": false,
892
+ "cli": "tonbo machine file list",
893
+ "cell": true
894
+ },
895
+ "issueSshCertificate": {
896
+ "method": "POST",
897
+ "path": "/ssh/certificates",
898
+ "pathParams": [],
899
+ "query": [],
900
+ "idempotencyKey": false,
901
+ "cli": "tonbo ssh certificate issue",
902
+ "cell": true
903
+ },
904
+ "getSshHostCa": {
905
+ "method": "GET",
906
+ "path": "/ssh/host-ca",
907
+ "pathParams": [],
908
+ "query": [],
909
+ "idempotencyKey": false,
910
+ "cli": "tonbo ssh host-ca",
911
+ "cell": true
912
+ },
807
913
  "listVolumes": {
808
914
  "method": "GET",
809
915
  "path": "/volumes",
@@ -3141,6 +3247,73 @@ async function openBrowser(url) {
3141
3247
  }
3142
3248
  }
3143
3249
 
3250
+ // ../../packages/tonbo-cli-core/dist/src/cell.js
3251
+ function cellOrigin(region, configured = process.env.TONBO_CELL_API_ORIGIN) {
3252
+ if (!/^[a-z0-9-]+$/.test(region))
3253
+ throw new Error(`${region} is not a region.`);
3254
+ const url = new URL((configured?.trim() || "https://api.{region}.tonbo.sh").replace("{region}", region));
3255
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
3256
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
3257
+ throw new Error("TONBO_CELL_API_ORIGIN must be an https origin.");
3258
+ return url.origin;
3259
+ }
3260
+ var CellApi = class {
3261
+ fetcher;
3262
+ constructor(fetcher) {
3263
+ this.fetcher = fetcher;
3264
+ }
3265
+ url(operationId, region, request) {
3266
+ const operation = managementOperations[operationId];
3267
+ const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
3268
+ const value = request.params[name];
3269
+ if (value === void 0)
3270
+ throw new Error(`${operationId} needs the path parameter ${name}.`);
3271
+ return encodeURIComponent(value);
3272
+ });
3273
+ const search = new URLSearchParams();
3274
+ for (const [name, value] of Object.entries(request.query ?? {}))
3275
+ if (value !== void 0)
3276
+ search.set(name, value);
3277
+ const query = search.toString();
3278
+ return `${cellOrigin(region)}${managementApiBasePath}${path4}${query ? `?${query}` : ""}`;
3279
+ }
3280
+ async init(operationId, key, request) {
3281
+ const headers = {
3282
+ authorization: `Bearer ${await managementBearer(key)}`
3283
+ };
3284
+ for (const [name, value] of Object.entries(request.headers ?? {}))
3285
+ if (value !== void 0)
3286
+ headers[name.toLowerCase()] = value;
3287
+ return { method: managementOperations[operationId].method, headers, body: request.body };
3288
+ }
3289
+ /** The raw answer; a refusal throws an HttpError naming the request id. */
3290
+ async fetch(operationId, key, region, request) {
3291
+ const url = this.url(operationId, region, request);
3292
+ const response = await this.fetcher(url, await this.init(operationId, key, request));
3293
+ if (response.ok)
3294
+ return response;
3295
+ const answered = () => Promise.resolve(response);
3296
+ await requestJson(answered, url);
3297
+ throw new HttpError(`Request failed with HTTP ${response.status}.`, response.status, null, null);
3298
+ }
3299
+ /** The answer's `data`. */
3300
+ async json(operationId, key, region, request) {
3301
+ const url = this.url(operationId, region, request);
3302
+ const answer = await requestJson(this.fetcher, url, await this.init(operationId, key, request));
3303
+ return answer.data;
3304
+ }
3305
+ /** machine-v2.json `op_kill` (`DELETE /v1/machines/{name}/ops/{op}`), which the Management
3306
+ * API document leaves out as superseded by the planned process kill. Answers the op's record. */
3307
+ async killOp(key, region, name, op) {
3308
+ const url = `${cellOrigin(region)}${managementApiBasePath}/machines/${encodeURIComponent(name)}/ops/${encodeURIComponent(op)}`;
3309
+ const answer = await requestJson(this.fetcher, url, {
3310
+ method: "DELETE",
3311
+ headers: { authorization: `Bearer ${await managementBearer(key)}` }
3312
+ });
3313
+ return answer.data;
3314
+ }
3315
+ };
3316
+
3144
3317
  // ../../packages/tonbo-cli-core/dist/src/format.js
3145
3318
  function table(headings, rows) {
3146
3319
  const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
@@ -4415,7 +4588,7 @@ var MACHINE_ERROR_HINTS = {
4415
4588
  precondition_failed: "The Machine changed since it was read, or another Machine now has its name; `tonbo machine show` it and run the command again.",
4416
4589
  machine_failed: "A Machine whose creation failed does not change: `tonbo machine show` says why; delete it with `tonbo machine delete` and create it again.",
4417
4590
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
4418
- machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`), or ask for more.",
4591
+ machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`).",
4419
4592
  database_bound: "Another Machine binds that database, and a database binds one Machine: name another, or delete the Machine that binds it first.",
4420
4593
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
4421
4594
  create_rate_limited: "Too many Machines were created in the last hour; try again later.",
@@ -4617,7 +4790,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
4617
4790
  ["HTTPS", machine.hostnames.https],
4618
4791
  // The resource keeps the SSH destination as a receipt; SSH is not served
4619
4792
  // before tier P3.
4620
- ["SSH", `${machine.hostnames.ssh} (not served yet)`],
4793
+ ["SSH", `${machine.hostnames.ssh} (tonbo ssh ${machine.name})`],
4621
4794
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
4622
4795
  ["Stop grace", `${machine.stop_grace_seconds} s`],
4623
4796
  ["Volume", volumeText(machine)],
@@ -4708,11 +4881,7 @@ function parseEnv(pairs) {
4708
4881
  return env;
4709
4882
  }
4710
4883
  var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
4711
- function specWarnings(deps, ports, envKeys) {
4712
- if (ports.length === 0)
4713
- deps.notice(
4714
- "This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
4715
- );
4884
+ function specWarnings(deps, _ports, envKeys) {
4716
4885
  const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
4717
4886
  if (secret.length)
4718
4887
  deps.notice(
@@ -5199,6 +5368,10 @@ var ENVIRONMENT = [
5199
5368
  names: ["TONBO_API_ORIGIN"],
5200
5369
  description: "The Management API origin, `https://api.tonbo.dev` when unset. It must be https, or http on a loopback address. `tonbo login` opens the login page on the same origin without its `api.` label."
5201
5370
  },
5371
+ {
5372
+ names: ["TONBO_CELL_API_ORIGIN"],
5373
+ description: "The cell origin `tonbo machine exec` and the file commands call, with `{region}` for the Machine's region; `https://api.{region}.tonbo.sh` when unset. It must be https, or http on a loopback address."
5374
+ },
5202
5375
  {
5203
5376
  names: ["XDG_CONFIG_HOME"],
5204
5377
  description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/login.json` when it is set and not empty, else `$HOME/.config/tonbo/login.json`. A login an earlier release saved, `credentials.json` beside it, is read only when there is no `login.json`."
@@ -5581,6 +5754,59 @@ var COMMAND_HELP = {
5581
5754
  "--if-match": "the ETag the command reads"
5582
5755
  }
5583
5756
  },
5757
+ "tonbo machine exec": {
5758
+ about: [
5759
+ "Runs one command in the Machine's running activation through its region's cell origin, waking the Machine first when it is asleep, and streams its stdout and stderr here as they come; a call that woke the Machine prints its hourly ceiling on stderr. The command runs without a shell, as the image's User, and lives exactly as long as the call: there is no terminal and no streamed stdin, and `--stdin` sends at most 1 MiB read before the call.",
5760
+ "The CLI exits with the command's status: 128 plus the signal's number when a signal ended it, 124 at `--timeout`, 125 when it was refused, killed or passed 64 MiB of output, 126 or 127 when it could not run, and 255 when the connection ended first and the outcome is unknown. `--json` prints the answer's document instead, with the first and last 512 KiB of each stream."
5761
+ ],
5762
+ examples: [
5763
+ "tonbo machine exec web -- ls -la /app",
5764
+ "tonbo machine exec web --timeout 60 -- npm test"
5765
+ ],
5766
+ arguments: {
5767
+ name: "the Machine's name",
5768
+ command: "the command and its arguments, after `--`"
5769
+ },
5770
+ defaults: { "--timeout": "600", "--cwd": "the image's WorkingDir" }
5771
+ },
5772
+ "tonbo machine file read": {
5773
+ about: [
5774
+ "Prints one regular file of the Machine, waking it when it is asleep. A file is gone when the Machine sleeps, so a read that woke it sees only its image."
5775
+ ],
5776
+ examples: [
5777
+ "tonbo machine file read web /etc/hostname",
5778
+ "tonbo machine file read web /app/out.tar --output out.tar"
5779
+ ],
5780
+ arguments: { name: "the Machine's name", path: "the file's absolute path in the Machine" }
5781
+ },
5782
+ "tonbo machine file write": {
5783
+ about: [
5784
+ "Replaces one file of the Machine atomically with stdin, or with `--from`, at most 64 MiB, creating missing directories; a reader sees the old file or the new one. `--if-match` replaces only the version `tonbo machine file stat` printed, and `--create` only creates. The file lasts until the Machine sleeps."
5785
+ ],
5786
+ examples: ["tonbo machine file write web /app/config.json --from config.json"],
5787
+ arguments: { name: "the Machine's name", path: "the file's absolute path in the Machine" }
5788
+ },
5789
+ "tonbo machine file stat": {
5790
+ about: [
5791
+ "Prints one path's type, size, mode, owner, modification time and ETag, without following a symbolic link."
5792
+ ],
5793
+ examples: ["tonbo machine file stat web /app/config.json"],
5794
+ arguments: { name: "the Machine's name", path: "the absolute path in the Machine" }
5795
+ },
5796
+ "tonbo machine file list": {
5797
+ about: [
5798
+ "Lists one directory of the Machine, not recursively, sorted by name; at most 10,000 entries."
5799
+ ],
5800
+ examples: ["tonbo machine file list web /app"],
5801
+ arguments: { name: "the Machine's name", path: "the directory's absolute path in the Machine" }
5802
+ },
5803
+ "tonbo machine file delete": {
5804
+ about: [
5805
+ "Deletes one file, symbolic link (never its target) or empty directory of the Machine, and a directory with everything in it with `--recursive`. It never deletes `/`, a mount point or Tonbo's own paths."
5806
+ ],
5807
+ examples: ["tonbo machine file delete web /app/tmp --recursive"],
5808
+ arguments: { name: "the Machine's name", path: "the absolute path in the Machine" }
5809
+ },
5584
5810
  "tonbo machine sleep": {
5585
5811
  about: [
5586
5812
  "Puts an awake Machine to sleep now, and says so when it is already asleep or still creating; its next request boots it on its latest spec. With `--wait` the CLI reads the Machine until it is asleep or `--timeout` passes."
@@ -5613,6 +5839,40 @@ var COMMAND_HELP = {
5613
5839
  examples: ["tonbo machine shapes"],
5614
5840
  json: "answer"
5615
5841
  },
5842
+ "tonbo ssh": {
5843
+ about: [
5844
+ "Opens a terminal on the Machine, or runs the command after `--` there, with your system's `ssh`. The CLI keeps one key for this computer in `~/.config/tonbo/ssh`, gets a short-lived certificate for you from the Machine's region whenever the one it holds is about to expire, and trusts Tonbo's host CA, so nothing is installed in the Machine's image. A Machine that is asleep wakes for the login.",
5845
+ "A certificate lasts at most 10 minutes and only has to be valid when you log in: an open session stays open. A session counts as a connection, so the Machine stays awake while you use it, and the connection closes after 60 minutes with nothing typed."
5846
+ ],
5847
+ examples: ["tonbo ssh web", "tonbo ssh web -- uname -a"]
5848
+ },
5849
+ "tonbo ssh config": {
5850
+ about: [
5851
+ "Prints an ssh_config block for every Machine. Append it to `~/.ssh/config` and plain `ssh <name>.<account>@<region>.tonbo.sh`, `scp`, `sftp`, `rsync -e ssh` and VS Code or Cursor Remote-SSH work, with `tonbo ssh refresh` renewing the certificate before each connection that needs it. Port forwarding with `-L` reaches the Machine's own loopback."
5852
+ ],
5853
+ examples: ["tonbo ssh config >> ~/.ssh/config"]
5854
+ },
5855
+ "tonbo ssh host-ca": {
5856
+ about: [
5857
+ "Prints the `@cert-authority` lines that let OpenSSH verify Tonbo's SSH hosts, one per host CA key every region trusts, for a known_hosts file of your own. `tonbo ssh` and `tonbo ssh config` keep their own copy; this is for setups without the CLI."
5858
+ ],
5859
+ examples: ["tonbo ssh host-ca --region eu-test-1 >> ~/.ssh/known_hosts"]
5860
+ },
5861
+ "tonbo ssh certificate issue": {
5862
+ about: [
5863
+ "Signs your own public key into a certificate of the account for SSH to its Machines, for a key the CLI does not keep, such as one in CI. The certificate is valid for at most 10 minutes, and never past the login's token, so ask again before each login; an open session outlives it. Use it with `ssh -i <key> -o CertificateFile=<certificate> <name>.<account>@<region>.tonbo.sh`."
5864
+ ],
5865
+ examples: [
5866
+ "tonbo ssh certificate issue --region eu-test-1 --public-key ~/.ssh/ci.pub > ~/.ssh/ci-cert.pub"
5867
+ ],
5868
+ defaults: { "--ttl": "the API's default; the certificate lasts at most 10 minutes" }
5869
+ },
5870
+ "tonbo ssh refresh": {
5871
+ about: [
5872
+ "Gets a new certificate for the destination's account when the one this computer holds expires within two minutes, and exits 0 once a valid one is held. `ssh` runs it through the `Match exec` line of `tonbo ssh config`; you rarely run it yourself."
5873
+ ],
5874
+ examples: ["tonbo ssh refresh web.acme@eu-test-1.tonbo.sh"]
5875
+ },
5616
5876
  "tonbo volume list": {
5617
5877
  about: [
5618
5878
  "Lists the account's Volumes in every region with each one's state, the Machine bound to it, its size and its live bytes."
@@ -5731,6 +5991,283 @@ function applyHelp(program) {
5731
5991
  return program;
5732
5992
  }
5733
5993
 
5994
+ // src/machine-cell.ts
5995
+ import { readFile as readFile3, writeFile } from "node:fs/promises";
5996
+ import { randomUUID as randomUUID6 } from "node:crypto";
5997
+ var EXEC_EXIT_CODES = {
5998
+ timeout: 124,
5999
+ output_limit: 125,
6000
+ killed: 125,
6001
+ lost: 255,
6002
+ refused: 125,
6003
+ command_not_executable: 126,
6004
+ command_not_found: 127
6005
+ };
6006
+ var SIGNALS = {
6007
+ SIGHUP: 1,
6008
+ SIGINT: 2,
6009
+ SIGQUIT: 3,
6010
+ SIGILL: 4,
6011
+ SIGTRAP: 5,
6012
+ SIGABRT: 6,
6013
+ SIGBUS: 7,
6014
+ SIGFPE: 8,
6015
+ SIGKILL: 9,
6016
+ SIGUSR1: 10,
6017
+ SIGSEGV: 11,
6018
+ SIGUSR2: 12,
6019
+ SIGPIPE: 13,
6020
+ SIGALRM: 14,
6021
+ SIGTERM: 15
6022
+ };
6023
+ function execExitCode(end) {
6024
+ switch (end.reason) {
6025
+ case "exited":
6026
+ return end.exit_code ?? 0;
6027
+ case "signaled":
6028
+ return 128 + (SIGNALS[end.signal ?? ""] ?? 0);
6029
+ default:
6030
+ return EXEC_EXIT_CODES[end.reason];
6031
+ }
6032
+ }
6033
+ function execRefusalExitCode(error) {
6034
+ if (error instanceof HttpError) {
6035
+ if (error.code === "command_not_found") return EXEC_EXIT_CODES.command_not_found;
6036
+ if (error.code === "command_not_executable") return EXEC_EXIT_CODES.command_not_executable;
6037
+ if (error.code === "lost") return EXEC_EXIT_CODES.lost;
6038
+ }
6039
+ return EXEC_EXIT_CODES.refused;
6040
+ }
6041
+ function cellText(value) {
6042
+ if (value === null || value === void 0) return "-";
6043
+ return typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : JSON.stringify(value);
6044
+ }
6045
+ function cellApi(deps) {
6046
+ return deps.cell ?? new CellApi(fetch);
6047
+ }
6048
+ function writeBytes(deps, stream, bytes2) {
6049
+ if (deps.write) deps.write(stream, bytes2);
6050
+ else (stream === "stdout" ? process.stdout : process.stderr).write(bytes2);
6051
+ }
6052
+ function exitWith(deps, code) {
6053
+ if (deps.exit) deps.exit(code);
6054
+ else process.exitCode = code;
6055
+ }
6056
+ async function readAllStdin(deps) {
6057
+ if (deps.readStdin) return deps.readStdin();
6058
+ const chunks = [];
6059
+ for await (const chunk of process.stdin) chunks.push(chunk);
6060
+ return new Uint8Array(Buffer.concat(chunks));
6061
+ }
6062
+ async function regionOf(deps, key, name) {
6063
+ const machine = await deps.api.getMachine(key, name);
6064
+ if (machine.kind !== "managed")
6065
+ throw new Error(
6066
+ `${name} is one of your computers; exec and files reach only managed Machines.`
6067
+ );
6068
+ return machine.region;
6069
+ }
6070
+ function noticeWake(deps, ref, name) {
6071
+ if (!ref?.woke) return;
6072
+ const price = ref.ceiling_usd_micros_per_hour === null ? "an unknown price" : `at most ${usd(ref.ceiling_usd_micros_per_hour)} an hour`;
6073
+ deps.notice(`Woke ${name} (activation ${ref.activation}), ${price} while it stays awake.`);
6074
+ }
6075
+ function parseEnv2(pairs) {
6076
+ const env = {};
6077
+ for (const pair of pairs) {
6078
+ const at = pair.indexOf("=");
6079
+ if (at <= 0) throw new Error(`--env ${pair} is not KEY=VALUE.`);
6080
+ env[pair.slice(0, at)] = pair.slice(at + 1);
6081
+ }
6082
+ return env;
6083
+ }
6084
+ async function machineExecCommand(deps, name, command, options = {}) {
6085
+ if (command.length === 0)
6086
+ throw new Error("Name the command after --, such as `tonbo machine exec web -- ls /`.");
6087
+ const key = await deps.auth.apiKey();
6088
+ const region = await regionOf(deps, key, name);
6089
+ const body = { command };
6090
+ if (options.cwd) body.cwd = options.cwd;
6091
+ if (options.env?.length) body.env = parseEnv2(options.env);
6092
+ if (options.timeout !== void 0) body.timeout_seconds = options.timeout;
6093
+ if (options.expectActivation) body.expect_activation = options.expectActivation;
6094
+ if (options.stdin) body.stdin = Buffer.from(await readAllStdin(deps)).toString("base64");
6095
+ let response;
6096
+ try {
6097
+ response = await cellApi(deps).fetch("execMachineCommand", key, region, {
6098
+ params: { name },
6099
+ headers: {
6100
+ "content-type": "application/json",
6101
+ accept: options.json ? "application/json" : "application/x-ndjson",
6102
+ "idempotency-key": options.idempotencyKey
6103
+ },
6104
+ body: JSON.stringify(body)
6105
+ });
6106
+ } catch (error) {
6107
+ exitWith(deps, execRefusalExitCode(error));
6108
+ throw explainMachineError(error);
6109
+ }
6110
+ if (options.json) {
6111
+ const answer = await response.json();
6112
+ print(deps, true, answer.data, "");
6113
+ exitWith(deps, answer.data.reason ? execExitCode(answer.data) : 0);
6114
+ return;
6115
+ }
6116
+ const decoder = new TextDecoder();
6117
+ let buffered = "";
6118
+ let end = null;
6119
+ const line = (text) => {
6120
+ if (!text.trim()) return;
6121
+ const frame = JSON.parse(text);
6122
+ if (frame.type === "start") noticeWake(deps, frame.machine, name);
6123
+ else if (frame.type === "stdout" || frame.type === "stderr")
6124
+ writeBytes(deps, frame.type, new Uint8Array(Buffer.from(frame.data ?? "", "base64")));
6125
+ else if (frame.type === "end") end = frame;
6126
+ };
6127
+ if (response.headers.get("content-type")?.startsWith("application/json")) {
6128
+ const answer = await response.json();
6129
+ print(deps, true, answer.data, "");
6130
+ return;
6131
+ }
6132
+ const reader = response.body?.getReader();
6133
+ while (reader) {
6134
+ const { done, value } = await reader.read();
6135
+ if (done) break;
6136
+ buffered += decoder.decode(value, { stream: true });
6137
+ let at;
6138
+ while ((at = buffered.indexOf("\n")) >= 0) {
6139
+ line(buffered.slice(0, at));
6140
+ buffered = buffered.slice(at + 1);
6141
+ }
6142
+ }
6143
+ line(buffered);
6144
+ const ended = end ?? { reason: "lost", exit_code: null, signal: null };
6145
+ if (ended.reason !== "exited" && ended.reason !== "signaled")
6146
+ deps.notice(`The command ended: ${ended.reason}.`);
6147
+ exitWith(deps, execExitCode(ended));
6148
+ }
6149
+ async function machineFileReadCommand(deps, name, path4, options = {}) {
6150
+ try {
6151
+ const key = await deps.auth.apiKey();
6152
+ const region = await regionOf(deps, key, name);
6153
+ const response = await cellApi(deps).fetch("readMachineFile", key, region, {
6154
+ params: { name },
6155
+ query: { path: path4 },
6156
+ headers: { range: options.range, "tonbo-expect-activation": options.expectActivation }
6157
+ });
6158
+ const bytes2 = new Uint8Array(await response.arrayBuffer());
6159
+ if (options.output) await writeFile(options.output, bytes2);
6160
+ else writeBytes(deps, "stdout", bytes2);
6161
+ } catch (error) {
6162
+ throw explainMachineError(error);
6163
+ }
6164
+ }
6165
+ async function machineFileWriteCommand(deps, name, path4, options = {}) {
6166
+ try {
6167
+ const bytes2 = options.from ? new Uint8Array(await readFile3(options.from)) : await readAllStdin(deps);
6168
+ const key = await deps.auth.apiKey();
6169
+ const region = await regionOf(deps, key, name);
6170
+ const written = await cellApi(deps).json("writeMachineFile", key, region, {
6171
+ params: { name },
6172
+ query: { path: path4 },
6173
+ headers: {
6174
+ "content-type": "application/octet-stream",
6175
+ "if-match": options.ifMatch,
6176
+ "if-none-match": options.create ? "*" : void 0,
6177
+ "tonbo-file-mode": options.mode,
6178
+ "tonbo-expect-activation": options.expectActivation,
6179
+ "idempotency-key": randomUUID6()
6180
+ },
6181
+ body: new Uint8Array(bytes2)
6182
+ });
6183
+ noticeWake(deps, written.machine, name);
6184
+ print(
6185
+ deps,
6186
+ options.json,
6187
+ written,
6188
+ `${written.created ? "Created" : "Replaced"} ${written.path} (${written.size} bytes, ${written.etag}); it lasts until the Machine sleeps.`
6189
+ );
6190
+ } catch (error) {
6191
+ throw explainMachineError(error);
6192
+ }
6193
+ }
6194
+ async function machineFileStatCommand(deps, name, path4, options = {}) {
6195
+ try {
6196
+ const key = await deps.auth.apiKey();
6197
+ const region = await regionOf(deps, key, name);
6198
+ const stat = await cellApi(deps).json(
6199
+ "statMachineFile",
6200
+ key,
6201
+ region,
6202
+ {
6203
+ params: { name },
6204
+ query: { path: path4 },
6205
+ headers: { "tonbo-expect-activation": options.expectActivation }
6206
+ }
6207
+ );
6208
+ noticeWake(deps, stat.machine, name);
6209
+ print(
6210
+ deps,
6211
+ options.json,
6212
+ stat,
6213
+ fields(
6214
+ ["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
6215
+ (field) => [field, cellText(stat[field])]
6216
+ )
6217
+ )
6218
+ );
6219
+ } catch (error) {
6220
+ throw explainMachineError(error);
6221
+ }
6222
+ }
6223
+ async function machineFileListCommand(deps, name, path4, options = {}) {
6224
+ try {
6225
+ const key = await deps.auth.apiKey();
6226
+ const region = await regionOf(deps, key, name);
6227
+ const listing = await cellApi(deps).json("listMachineFiles", key, region, {
6228
+ params: { name },
6229
+ query: { path: path4 },
6230
+ headers: { "tonbo-expect-activation": options.expectActivation }
6231
+ });
6232
+ noticeWake(deps, listing.machine, name);
6233
+ print(
6234
+ deps,
6235
+ options.json,
6236
+ listing,
6237
+ table(
6238
+ ["NAME", "TYPE", "SIZE", "MODE", "MODIFIED"],
6239
+ listing.entries.map((entry) => [
6240
+ entry.name ?? `(base64) ${entry.name_b64}`,
6241
+ entry.type,
6242
+ String(entry.size),
6243
+ entry.mode,
6244
+ entry.mtime
6245
+ ])
6246
+ )
6247
+ );
6248
+ if (listing.truncated) deps.notice("The directory has more entries than one listing holds.");
6249
+ } catch (error) {
6250
+ throw explainMachineError(error);
6251
+ }
6252
+ }
6253
+ async function machineFileDeleteCommand(deps, name, path4, options = {}) {
6254
+ try {
6255
+ const key = await deps.auth.apiKey();
6256
+ const region = await regionOf(deps, key, name);
6257
+ await cellApi(deps).fetch("deleteMachineFile", key, region, {
6258
+ params: { name },
6259
+ query: { path: path4, recursive: options.recursive ? "true" : void 0 },
6260
+ headers: {
6261
+ "tonbo-expect-activation": options.expectActivation,
6262
+ "idempotency-key": randomUUID6()
6263
+ }
6264
+ });
6265
+ print(deps, options.json, { path: path4 }, `Deleted ${path4}.`);
6266
+ } catch (error) {
6267
+ throw explainMachineError(error);
6268
+ }
6269
+ }
6270
+
5734
6271
  // src/prompt.ts
5735
6272
  import select from "@inquirer/select";
5736
6273
  import { createInterface } from "node:readline/promises";
@@ -5756,6 +6293,237 @@ async function terminalAsk(question) {
5756
6293
  }
5757
6294
  }
5758
6295
 
6296
+ // src/ssh.ts
6297
+ import { spawn, spawnSync } from "node:child_process";
6298
+ import { existsSync } from "node:fs";
6299
+ import { chmod as chmod2, mkdir as mkdir3, readFile as readFile4, readdir, rename as rename3, writeFile as writeFile2 } from "node:fs/promises";
6300
+ import { homedir } from "node:os";
6301
+ import { join } from "node:path";
6302
+ function sshDirectory(env = process.env) {
6303
+ const config = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
6304
+ return join(config, "tonbo", "ssh");
6305
+ }
6306
+ var RENEW_BEFORE_MS = 12e4;
6307
+ var REQUESTED_TTL_SECONDS = 3600;
6308
+ function parseDestination(destination) {
6309
+ const at = destination.lastIndexOf("@");
6310
+ const user = destination.slice(0, at);
6311
+ const host = destination.slice(at + 1).toLowerCase();
6312
+ const dot = user.lastIndexOf(".");
6313
+ if (at <= 0 || dot <= 0 || dot === user.length - 1 || !host.includes(".")) {
6314
+ throw new Error(`${destination} is not a Machine's SSH destination (<name>.<account>@<host>)`);
6315
+ }
6316
+ return { user, host, account: user.slice(dot + 1) };
6317
+ }
6318
+ function cellOrigin2(host) {
6319
+ return `https://api.${host}`;
6320
+ }
6321
+ async function ensureKey(directory) {
6322
+ const key = join(directory, "id_ed25519");
6323
+ await mkdir3(directory, { recursive: true, mode: 448 });
6324
+ await chmod2(directory, 448);
6325
+ if (!existsSync(key)) {
6326
+ const made = spawnSync(
6327
+ "ssh-keygen",
6328
+ ["-q", "-t", "ed25519", "-N", "", "-C", "tonbo", "-f", key],
6329
+ {
6330
+ stdio: ["ignore", "ignore", "inherit"]
6331
+ }
6332
+ );
6333
+ if (made.error || made.status !== 0) {
6334
+ throw new Error(
6335
+ "ssh-keygen could not make the key ~/.config/tonbo/ssh/id_ed25519; install OpenSSH's client tools and run the command again"
6336
+ );
6337
+ }
6338
+ }
6339
+ return key;
6340
+ }
6341
+ async function writeAtomic(path4, data) {
6342
+ const temporary = `${path4}.${process.pid}.tmp`;
6343
+ await writeFile2(temporary, data, { mode: 384 });
6344
+ await rename3(temporary, path4);
6345
+ }
6346
+ function certificatePath(directory, user) {
6347
+ return join(directory, `${user}-cert.pub`);
6348
+ }
6349
+ function hostCAPath(directory, host) {
6350
+ if (!/^[a-z0-9][a-z0-9.-]*$/.test(host)) {
6351
+ throw new Error(`${host} is not a Machine's SSH host`);
6352
+ }
6353
+ return join(directory, "host-ca", host);
6354
+ }
6355
+ async function trustHostCA(directory, host, lines) {
6356
+ const cells = join(directory, "host-ca");
6357
+ await mkdir3(cells, { recursive: true, mode: 448 });
6358
+ await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
6359
+ `);
6360
+ const trusted = /* @__PURE__ */ new Set();
6361
+ for (const name of (await readdir(cells)).sort()) {
6362
+ if (name.endsWith(".tmp")) {
6363
+ continue;
6364
+ }
6365
+ for (const line of (await readFile4(join(cells, name), "utf8")).split("\n")) {
6366
+ if (line.trim() !== "") {
6367
+ trusted.add(line.trim());
6368
+ }
6369
+ }
6370
+ }
6371
+ await writeAtomic(join(directory, "known_hosts"), `${[...trusted].join("\n")}
6372
+ `);
6373
+ }
6374
+ async function heldUntil(directory, user) {
6375
+ try {
6376
+ const sidecar = JSON.parse(
6377
+ await readFile4(`${certificatePath(directory, user)}.json`, "utf8")
6378
+ );
6379
+ return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
6380
+ } catch {
6381
+ return 0;
6382
+ }
6383
+ }
6384
+ async function cellRequest(bearer, method, url, body) {
6385
+ for (let attempt = 0; ; attempt++) {
6386
+ const token = await managementBearer(bearer);
6387
+ const response = await fetch(url, {
6388
+ method,
6389
+ headers: {
6390
+ Authorization: `Bearer ${token}`,
6391
+ Accept: "application/json",
6392
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
6393
+ },
6394
+ body: body === void 0 ? void 0 : JSON.stringify(body)
6395
+ });
6396
+ if (response.status === 401 && attempt === 0 && isAccountBearer(bearer)) {
6397
+ bearer.invalidate(MANAGEMENT_AUDIENCE);
6398
+ continue;
6399
+ }
6400
+ const answer = await response.json().catch(() => null);
6401
+ if (!response.ok) {
6402
+ throw new Error(
6403
+ `${url} answered ${response.status}${answer?.code ? ` ${answer.code}` : ""}${answer?.detail ? `: ${answer.detail}` : ""}`
6404
+ );
6405
+ }
6406
+ return answer?.data;
6407
+ }
6408
+ }
6409
+ async function refreshCertificate(bearer, destination, now = Date.now, directory = sshDirectory()) {
6410
+ const key = await ensureKey(directory);
6411
+ const certificate = certificatePath(directory, destination.user);
6412
+ const knownHosts = join(directory, "known_hosts");
6413
+ if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync(hostCAPath(directory, destination.host)) && existsSync(knownHosts)) {
6414
+ return { key, certificate, knownHosts };
6415
+ }
6416
+ const origin = cellOrigin2(destination.host);
6417
+ const publicKey = (await readFile4(`${key}.pub`, "utf8")).trim();
6418
+ const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
6419
+ public_key: publicKey,
6420
+ ttl_seconds: REQUESTED_TTL_SECONDS
6421
+ });
6422
+ if (typeof issued?.certificate !== "string" || typeof issued.valid_before !== "string") {
6423
+ throw new Error(`${origin} answered no certificate`);
6424
+ }
6425
+ await writeAtomic(certificate, `${issued.certificate}
6426
+ `);
6427
+ await writeAtomic(
6428
+ `${certificate}.json`,
6429
+ `${JSON.stringify({ valid_before: issued.valid_before })}
6430
+ `
6431
+ );
6432
+ const hostCA = await cellRequest(bearer, "GET", `${origin}/v1/ssh/host-ca`);
6433
+ if (!Array.isArray(hostCA?.known_hosts) || hostCA.known_hosts.length === 0) {
6434
+ throw new Error(`${origin} answered no host CA`);
6435
+ }
6436
+ await trustHostCA(directory, destination.host, hostCA.known_hosts);
6437
+ return { key, certificate, knownHosts };
6438
+ }
6439
+ function sshOptions(paths) {
6440
+ return [
6441
+ "-i",
6442
+ paths.key,
6443
+ "-o",
6444
+ `CertificateFile=${paths.certificate}`,
6445
+ "-o",
6446
+ "IdentitiesOnly=yes",
6447
+ "-o",
6448
+ `UserKnownHostsFile=${paths.knownHosts}`,
6449
+ "-o",
6450
+ "StrictHostKeyChecking=yes"
6451
+ ];
6452
+ }
6453
+ async function sshCommand(deps, name, command) {
6454
+ let destination;
6455
+ let paths;
6456
+ try {
6457
+ const bearer = await deps.auth.apiKey();
6458
+ const { machine } = await deps.api.getMachineWithEtag(bearer, name);
6459
+ if (machine.kind !== "managed") {
6460
+ throw new Error(`${name} is one of your computers; SSH reaches only Tonbo-run Machines`);
6461
+ }
6462
+ destination = parseDestination(machine.hostnames.ssh);
6463
+ paths = await refreshCertificate(bearer, destination, deps.now);
6464
+ } catch (error) {
6465
+ throw explainMachineError(error);
6466
+ }
6467
+ const target = `${destination.user}@${destination.host}`;
6468
+ const status = await new Promise((resolve, reject) => {
6469
+ const child = spawn("ssh", [...sshOptions(paths), target, ...command], { stdio: "inherit" });
6470
+ child.on(
6471
+ "error",
6472
+ (error) => reject(new Error(`could not run ssh (${error.message}); install an OpenSSH client`))
6473
+ );
6474
+ child.on("exit", (code, signal) => resolve(code ?? (signal ? 255 : 0)));
6475
+ });
6476
+ process.exitCode = status;
6477
+ }
6478
+ async function sshRefreshCommand(makeDeps, target) {
6479
+ const destination = parseDestination(target);
6480
+ const deps = makeDeps(destination.account);
6481
+ await refreshCertificate(await deps.auth.apiKey(), destination, deps.now);
6482
+ }
6483
+ function sshConfigText(directory = sshDirectory()) {
6484
+ const home = homedir();
6485
+ const shown = directory.startsWith(home) ? `~${directory.slice(home.length)}` : directory;
6486
+ return [
6487
+ "# Tonbo Machines: append to ~/.ssh/config (tonbo ssh config >> ~/.ssh/config).",
6488
+ "# Then: ssh <name>.<account>@<region>.tonbo.sh, scp, sftp and Remote-SSH work as usual.",
6489
+ `Match host "*.tonbo.sh,tonbo.sh" exec "tonbo ssh refresh %r@%h"`,
6490
+ ` IdentityFile ${shown}/id_ed25519`,
6491
+ ` CertificateFile ${shown}/%r-cert.pub`,
6492
+ " IdentitiesOnly yes",
6493
+ ` UserKnownHostsFile ${shown}/known_hosts`,
6494
+ " StrictHostKeyChecking yes",
6495
+ " ServerAliveInterval 30"
6496
+ ].join("\n");
6497
+ }
6498
+ async function sshHostCaCommand(deps, options) {
6499
+ const key = await deps.auth.apiKey();
6500
+ const data = await (deps.cell ?? new CellApi(fetch)).json(
6501
+ "getSshHostCa",
6502
+ key,
6503
+ options.region,
6504
+ { params: {} }
6505
+ );
6506
+ print(deps, options.json, data, data.known_hosts.join("\n"));
6507
+ }
6508
+ async function sshCertificateIssueCommand(deps, options) {
6509
+ const publicKey = (await readFile4(options.publicKey, "utf8")).trim();
6510
+ const key = await deps.auth.apiKey();
6511
+ const data = await (deps.cell ?? new CellApi(fetch)).json(
6512
+ "issueSshCertificate",
6513
+ key,
6514
+ options.region,
6515
+ {
6516
+ params: {},
6517
+ headers: { "content-type": "application/json" },
6518
+ body: JSON.stringify({
6519
+ public_key: publicKey,
6520
+ ...options.ttl === void 0 ? {} : { ttl_seconds: options.ttl }
6521
+ })
6522
+ }
6523
+ );
6524
+ print(deps, options.json, data, data.certificate);
6525
+ }
6526
+
5759
6527
  // src/app.ts
5760
6528
  var packageVersion = JSON.parse(
5761
6529
  readFileSync(new URL("../../package.json", import.meta.url), "utf8")
@@ -6156,6 +6924,59 @@ function createProgram(makeDependencies = createDependencies) {
6156
6924
  ).action(
6157
6925
  async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
6158
6926
  );
6927
+ operationOptions(
6928
+ machine.command("exec <name> [command...]").description(
6929
+ "run a command in a Machine, waking it first, and print its output; exits with its status"
6930
+ ).option("--cwd <path>", "the working directory, an absolute path").option(
6931
+ "--env <KEY=VALUE>",
6932
+ "set an environment variable for the command; repeat for several",
6933
+ repeated
6934
+ ).option(
6935
+ "--timeout <seconds>",
6936
+ "kill the command after this many seconds (default 600, at most 3600)",
6937
+ parseWhole
6938
+ ).option("--stdin", "send this process's stdin to the command (at most 1 MiB)").option(
6939
+ "--expect-activation <activation>",
6940
+ "run only in this activation; refuse and wake nothing otherwise"
6941
+ ).option(
6942
+ "--idempotency-key <key>",
6943
+ "a key that makes a repeat answer the first run instead of running again"
6944
+ ),
6945
+ "execMachineCommand"
6946
+ ).action(
6947
+ async (name, command, options) => machineExecCommand(dependencies(), name, command, options)
6948
+ );
6949
+ const file = machine.command("file").description("read, write, stat, list and delete a Machine's files");
6950
+ operationOptions(
6951
+ file.command("read <name> <path>").description("print a file of a Machine, waking it first").option("--output <file>", "write the bytes to this local file instead of stdout").option("--range <range>", "one byte range, such as bytes=0-1023").option("--expect-activation <activation>", "read only in this activation"),
6952
+ "readMachineFile"
6953
+ ).action(
6954
+ async (name, path4, options) => machineFileReadCommand(dependencies(), name, path4, options)
6955
+ );
6956
+ operationOptions(
6957
+ file.command("write <name> <path>").description("replace a file of a Machine with stdin or --from, atomically").option("--from <file>", "the local file to send instead of stdin").option("--mode <mode>", "the file's mode, four octal digits such as 0755").option("--if-match <etag>", "replace only the version with this ETag").option("--create", "only create the file; refuse when it exists").option("--expect-activation <activation>", "write only in this activation"),
6958
+ "writeMachineFile"
6959
+ ).action(
6960
+ async (name, path4, options) => machineFileWriteCommand(dependencies(), name, path4, options)
6961
+ );
6962
+ operationOptions(
6963
+ file.command("stat <name> <path>").description("show a path's type, size, mode, owner and ETag").option("--expect-activation <activation>", "stat only in this activation"),
6964
+ "statMachineFile"
6965
+ ).action(
6966
+ async (name, path4, options) => machineFileStatCommand(dependencies(), name, path4, options)
6967
+ );
6968
+ operationOptions(
6969
+ file.command("list <name> <path>").description("list a directory of a Machine").option("--expect-activation <activation>", "list only in this activation"),
6970
+ "listMachineFiles"
6971
+ ).action(
6972
+ async (name, path4, options) => machineFileListCommand(dependencies(), name, path4, options)
6973
+ );
6974
+ operationOptions(
6975
+ file.command("delete <name> <path>").description("delete a file, a symbolic link or an empty directory of a Machine").option("--recursive", "delete a directory and everything in it").option("--expect-activation <activation>", "delete only in this activation"),
6976
+ "deleteMachineFile"
6977
+ ).action(
6978
+ async (name, path4, options) => machineFileDeleteCommand(dependencies(), name, path4, options)
6979
+ );
6159
6980
  operationOptions(
6160
6981
  machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
6161
6982
  "sleepMachine"
@@ -6223,6 +7044,31 @@ function createProgram(makeDependencies = createDependencies) {
6223
7044
  ),
6224
7045
  "getUsageToday"
6225
7046
  ).action(async (options) => usageTodayCommand(dependencies(), options));
7047
+ const ssh = program.command("ssh").description("open a terminal on a Machine, or run a command there, over SSH").argument("<machine>", "the Machine's name").argument("[command...]", "a command to run instead of a shell, after --").action(async (name, command) => sshCommand(dependencies(), name, command));
7048
+ ssh.command("config").description(
7049
+ "print the ssh_config block that lets ssh, scp, sftp and Remote-SSH reach every Machine"
7050
+ ).action(() => dependencies().output(sshConfigText()));
7051
+ ssh.command("refresh").argument("<destination>", "a Machine's SSH destination, <name>.<account>@<host>").description(
7052
+ "renew the SSH certificate of <name>.<account>@<host> when it is about to expire (ssh_config's Match exec runs it)"
7053
+ ).action(
7054
+ async (destination) => sshRefreshCommand((account2) => makeDependencies({ account: account2 }), destination)
7055
+ );
7056
+ operationOptions(
7057
+ ssh.command("host-ca").description("print the known_hosts lines that trust Tonbo's SSH hosts").requiredOption("--region <region>", "the region whose cell origin to ask"),
7058
+ "getSshHostCa"
7059
+ ).action(
7060
+ async (options) => sshHostCaCommand(dependencies(), options)
7061
+ );
7062
+ const certificate = ssh.command("certificate").description("SSH certificates of the account for a key of your own");
7063
+ operationOptions(
7064
+ certificate.command("issue").description("sign an SSH public key into a certificate of the account").requiredOption("--region <region>", "the region whose cell origin signs it").requiredOption(
7065
+ "--public-key <file>",
7066
+ "an OpenSSH ssh-ed25519 or ecdsa-sha2-nistp256 public key file"
7067
+ ).option("--ttl <seconds>", "how long to ask for, 300 to 86400", parseWhole),
7068
+ "issueSshCertificate"
7069
+ ).action(
7070
+ async (options) => sshCertificateIssueCommand(dependencies(), options)
7071
+ );
6226
7072
  return applyHelp(program);
6227
7073
  }
6228
7074
 
@@ -6231,5 +7077,5 @@ try {
6231
7077
  await createProgram().parseAsync(process.argv);
6232
7078
  } catch (error) {
6233
7079
  console.error(error instanceof Error ? error.message : error);
6234
- process.exitCode = 1;
7080
+ if (!process.exitCode) process.exitCode = 1;
6235
7081
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "The Tonbo CLI: sign in and manage account resources from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {