@tonbo/cli 0.12.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 +928 -15
  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",
@@ -415,7 +426,8 @@ var managementOperations = {
415
426
  "pathParams": [],
416
427
  "query": [
417
428
  "after",
418
- "wait"
429
+ "wait",
430
+ "include"
419
431
  ],
420
432
  "idempotencyKey": false
421
433
  },
@@ -588,6 +600,61 @@ var managementOperations = {
588
600
  "query": [],
589
601
  "idempotencyKey": false
590
602
  },
603
+ "listYieldQuestions": {
604
+ "method": "GET",
605
+ "path": "/yield/questions",
606
+ "pathParams": [],
607
+ "query": [
608
+ "team",
609
+ "limit"
610
+ ],
611
+ "idempotencyKey": false
612
+ },
613
+ "createYieldQuestion": {
614
+ "method": "POST",
615
+ "path": "/yield/questions",
616
+ "pathParams": [],
617
+ "query": [],
618
+ "idempotencyKey": false
619
+ },
620
+ "showYieldQuestion": {
621
+ "method": "GET",
622
+ "path": "/yield/questions/{id}",
623
+ "pathParams": [
624
+ "id"
625
+ ],
626
+ "query": [],
627
+ "idempotencyKey": false
628
+ },
629
+ "replyYieldQuestion": {
630
+ "method": "POST",
631
+ "path": "/yield/questions/{id}/replies",
632
+ "pathParams": [
633
+ "id"
634
+ ],
635
+ "query": [],
636
+ "idempotencyKey": false
637
+ },
638
+ "closeYieldQuestion": {
639
+ "method": "POST",
640
+ "path": "/yield/questions/{id}/close",
641
+ "pathParams": [
642
+ "id"
643
+ ],
644
+ "query": [],
645
+ "idempotencyKey": false
646
+ },
647
+ "listYieldDecisions": {
648
+ "method": "GET",
649
+ "path": "/yield/decisions",
650
+ "pathParams": [],
651
+ "query": [
652
+ "project",
653
+ "path",
654
+ "limit"
655
+ ],
656
+ "idempotencyKey": false
657
+ },
591
658
  "listDatabases": {
592
659
  "method": "GET",
593
660
  "path": "/databases",
@@ -748,6 +815,101 @@ var managementOperations = {
748
815
  "idempotencyKey": false,
749
816
  "cli": "tonbo machine shapes"
750
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
+ },
751
913
  "listVolumes": {
752
914
  "method": "GET",
753
915
  "path": "/volumes",
@@ -879,7 +1041,7 @@ var apiKeyExpirations = [
879
1041
  ];
880
1042
  var credentialsContract = {
881
1043
  "version": 1,
882
- "description": "Legacy from ADR-0018: the per-account CLI login of @tonbo/cli 0.11 and @tonbo/yield 0.3. From @tonbo/cli 0.12 and @tonbo/yield 0.4 a login is one session of the person in login.json (credentials-v2.json); clients read this file only as the fallback when login.json is missing, and the per-account keys it holds are retired when those CLIs ship. Before that: the login `tonbo login` (and `yield login`) saves: a personal key of one account at the top level, the login made last, and, beside it, every account's login of this device in `accounts` with the device's `login_secret` (ADR-0016, \"Credentials\", PR 4b-ii). @tonbo/cli-core writes and reads it; the Tonbo SDK (@tonbo/sdk and tonbo-sdk, contracts/sdk/v1.json) reads it as its last key source, and every other reader follows `reader` and reads the top level alone.",
1044
+ "description": "Legacy from ADR-0018: the per-account CLI login of @tonbo/cli 0.11 and @tonbo/yield 0.3. From @tonbo/cli 0.12 and @tonbo/yield 0.4 a login is one session of the person in login.json (credentials-v2.json); clients read this file only as the fallback when login.json is missing, and the per-account keys it holds were retired when those CLIs shipped (founder, 2026-11-03): User Center issues them no more (access-token-v1.json `cli_session.login.retired`) and the live ones were revoked, so no client writes this file any longer. Before that: the login `tonbo login` (and `yield login`) saves: a personal key of one account at the top level, the login made last, and, beside it, every account's login of this device in `accounts` with the device's `login_secret` (ADR-0016, \"Credentials\", PR 4b-ii). @tonbo/cli-core writes and reads it; the Tonbo SDK (@tonbo/sdk and tonbo-sdk, contracts/sdk/v1.json) reads it as its last key source, and every other reader follows `reader` and reads the top level alone.",
883
1045
  "path": {
884
1046
  "description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
885
1047
  "base_env": "XDG_CONFIG_HOME",
@@ -3085,6 +3247,73 @@ async function openBrowser(url) {
3085
3247
  }
3086
3248
  }
3087
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
+
3088
3317
  // ../../packages/tonbo-cli-core/dist/src/format.js
3089
3318
  function table(headings, rows) {
3090
3319
  const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
@@ -4359,7 +4588,8 @@ var MACHINE_ERROR_HINTS = {
4359
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.",
4360
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.",
4361
4590
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
4362
- 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`).",
4592
+ database_bound: "Another Machine binds that database, and a database binds one Machine: name another, or delete the Machine that binds it first.",
4363
4593
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
4364
4594
  create_rate_limited: "Too many Machines were created in the last hour; try again later.",
4365
4595
  image_pulling: "The Machine's image is still being pulled; the request was not run. Try again shortly; `tonbo machine show` reports the image's state.",
@@ -4560,7 +4790,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
4560
4790
  ["HTTPS", machine.hostnames.https],
4561
4791
  // The resource keeps the SSH destination as a receipt; SSH is not served
4562
4792
  // before tier P3.
4563
- ["SSH", `${machine.hostnames.ssh} (not served yet)`],
4793
+ ["SSH", `${machine.hostnames.ssh} (tonbo ssh ${machine.name})`],
4564
4794
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
4565
4795
  ["Stop grace", `${machine.stop_grace_seconds} s`],
4566
4796
  ["Volume", volumeText(machine)],
@@ -4651,11 +4881,7 @@ function parseEnv(pairs) {
4651
4881
  return env;
4652
4882
  }
4653
4883
  var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
4654
- function specWarnings(deps, ports, envKeys) {
4655
- if (ports.length === 0)
4656
- deps.notice(
4657
- "This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
4658
- );
4884
+ function specWarnings(deps, _ports, envKeys) {
4659
4885
  const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
4660
4886
  if (secret.length)
4661
4887
  deps.notice(
@@ -4823,7 +5049,12 @@ async function machineCreateCommand(deps, name, command, options = {}) {
4823
5049
  const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
4824
5050
  const key = await deps.auth.apiKey();
4825
5051
  const bound = typeof options.volume === "string" ? await resolveVolumeBinding(deps, key, options.volume) : null;
4826
- const region = options.region ?? bound?.region;
5052
+ const database = typeof options.database === "string" ? await deps.api.getDatabase(key, options.database) : null;
5053
+ if (database && bound && database.region !== bound.region && !options.region)
5054
+ throw new Error(
5055
+ `The Volume is in ${bound.region} and database ${database.name} in ${database.region}; a Machine binds both only in its own region.`
5056
+ );
5057
+ const region = options.region ?? bound?.region ?? database?.region;
4827
5058
  const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
4828
5059
  let created;
4829
5060
  try {
@@ -4841,7 +5072,9 @@ async function machineCreateCommand(deps, name, command, options = {}) {
4841
5072
  idle_action: options.idleAction,
4842
5073
  stop_grace_seconds: options.stopGrace,
4843
5074
  ...bound ? { volumes: [bound.binding] } : {},
4844
- delete_after_seconds: deleteAfterSeconds(options.deleteAfter)
5075
+ delete_after_seconds: deleteAfterSeconds(options.deleteAfter),
5076
+ ...database ? { database: database.name } : {},
5077
+ ...options.database === false ? { database: null } : {}
4845
5078
  },
4846
5079
  options.idempotencyKey ?? randomUUID5()
4847
5080
  );
@@ -5135,6 +5368,10 @@ var ENVIRONMENT = [
5135
5368
  names: ["TONBO_API_ORIGIN"],
5136
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."
5137
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
+ },
5138
5375
  {
5139
5376
  names: ["XDG_CONFIG_HOME"],
5140
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`."
@@ -5475,7 +5712,7 @@ var COMMAND_HELP = {
5475
5712
  },
5476
5713
  "tonbo machine create": {
5477
5714
  about: [
5478
- "Creates a Machine, a VM booted from an image in one region. It has no database of its own: its files can last on a Volume (`--volume`), and its program opens a database by URL with a key. The image is a public OCI image with a linux/arm64 variant, from Docker Hub or another public registry (`nginx:1.27`, `ghcr.io/org/app:v1`, or one pinned by digest), or `tonbo.dev/platform`, the platform image. A registry reachable only over IPv6, one named by an IP address, and one that redirects its manifests are refused. The CLI resolves a registry image to its linux/arm64 digest, and the image is pulled before the Machine leaves `creating`.",
5715
+ "Creates a Machine, a VM booted from an image in one region. It has no database of its own: `--database <name>` binds one of the account's databases of its region, which it then reaches at `TONBO_DATABASE_URL`, and a database binds one Machine at a time; its files can last on a Volume (`--volume`), and its program can open any other database by URL with a key. The image is a public OCI image with a linux/arm64 variant, from Docker Hub or another public registry (`nginx:1.27`, `ghcr.io/org/app:v1`, or one pinned by digest), or `tonbo.dev/platform`, the platform image. A registry reachable only over IPv6, one named by an IP address, and one that redirects its manifests are refused. The CLI resolves a registry image to its linux/arm64 digest, and the image is pulled before the Machine leaves `creating`.",
5479
5716
  "The Machine is served at `https://<name>-<account>.tonbo.sh` on its default port. `--port <port>` serves a private port, which takes a member's token, and `--port <port>:public` an open one, which needs the `machines.public` capability. A command after `--` replaces the image's CMD. The CLI warns on stderr when the Machine has no port, since nothing can wake it then, and when an `--env` key looks secret: env values are readable by every member through the API.",
5480
5717
  "The answer is the Machine while it is `creating`. With `--wait` the CLI reads it until it is `active` or `failed`, or `--timeout` passes: a failure prints what to do and exits 1, and a timeout says it is still creating and undoes nothing. A creation the network or a 5xx answer did not confirm is sent at most three times with the same idempotency key, each retry after its `Retry-After` (at most 30 s) or a short backoff; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply. In a region not open for Machines the creation is refused with `region_not_offered`, and nothing is created."
5481
5718
  ],
@@ -5487,7 +5724,7 @@ var COMMAND_HELP = {
5487
5724
  defaults: {
5488
5725
  "--shape": "the shape `tonbo machine shapes` marks as the default",
5489
5726
  "--default-port": "the first --port",
5490
- "--region": "the --volume's region, else the account's default region",
5727
+ "--region": "the --volume's or --database's region, else the account's default region",
5491
5728
  "--idempotency-key": "a new UUID",
5492
5729
  "--timeout": String(DEFAULT_WAIT_SECONDS),
5493
5730
  "--delete-after": "never"
@@ -5517,6 +5754,59 @@ var COMMAND_HELP = {
5517
5754
  "--if-match": "the ETag the command reads"
5518
5755
  }
5519
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
+ },
5520
5810
  "tonbo machine sleep": {
5521
5811
  about: [
5522
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."
@@ -5549,6 +5839,40 @@ var COMMAND_HELP = {
5549
5839
  examples: ["tonbo machine shapes"],
5550
5840
  json: "answer"
5551
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
+ },
5552
5876
  "tonbo volume list": {
5553
5877
  about: [
5554
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."
@@ -5667,6 +5991,283 @@ function applyHelp(program) {
5667
5991
  return program;
5668
5992
  }
5669
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
+
5670
6271
  // src/prompt.ts
5671
6272
  import select from "@inquirer/select";
5672
6273
  import { createInterface } from "node:readline/promises";
@@ -5692,6 +6293,237 @@ async function terminalAsk(question) {
5692
6293
  }
5693
6294
  }
5694
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
+
5695
6527
  // src/app.ts
5696
6528
  var packageVersion = JSON.parse(
5697
6529
  readFileSync(new URL("../../package.json", import.meta.url), "utf8")
@@ -6062,7 +6894,10 @@ function createProgram(makeDependencies = createDependencies) {
6062
6894
  ).requiredOption(
6063
6895
  "--image <reference>",
6064
6896
  "the image the Machine boots: a public OCI image with a linux/arm64 variant (nginx:1.27, ghcr.io/org/app@sha256:...), or tonbo.dev/platform"
6065
- ).option("--region <region>", "the Machine's region, fixed for its life"),
6897
+ ).option("--region <region>", "the Machine's region, fixed for its life").option(
6898
+ "--database <name>",
6899
+ "bind the account's database <name> (see tonbo database list), of the Machine's region: its URL is TONBO_DATABASE_URL and the Machine writes it"
6900
+ ).option("--no-database", "bind no database (the default)"),
6066
6901
  false
6067
6902
  ).option(
6068
6903
  "--idempotency-key <key>",
@@ -6089,6 +6924,59 @@ function createProgram(makeDependencies = createDependencies) {
6089
6924
  ).action(
6090
6925
  async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
6091
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
+ );
6092
6980
  operationOptions(
6093
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),
6094
6982
  "sleepMachine"
@@ -6156,6 +7044,31 @@ function createProgram(makeDependencies = createDependencies) {
6156
7044
  ),
6157
7045
  "getUsageToday"
6158
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
+ );
6159
7072
  return applyHelp(program);
6160
7073
  }
6161
7074
 
@@ -6164,5 +7077,5 @@ try {
6164
7077
  await createProgram().parseAsync(process.argv);
6165
7078
  } catch (error) {
6166
7079
  console.error(error instanceof Error ? error.message : error);
6167
- process.exitCode = 1;
7080
+ if (!process.exitCode) process.exitCode = 1;
6168
7081
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.12.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": {