@tonbo/cli 0.14.0 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/bin/tonbo.js +206 -7
  2. package/package.json +1 -1
package/dist/bin/tonbo.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/app.ts
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
- import { readFileSync } from "node:fs";
5
+ import { readFileSync as readFileSync2 } from "node:fs";
6
6
 
7
7
  // ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
8
8
  var agentNameContract = {
@@ -815,6 +815,38 @@ var managementOperations = {
815
815
  "idempotencyKey": false,
816
816
  "cli": "tonbo machine shapes"
817
817
  },
818
+ "listMachineSecrets": {
819
+ "method": "GET",
820
+ "path": "/machines/{name}/secrets",
821
+ "pathParams": [
822
+ "name"
823
+ ],
824
+ "query": [],
825
+ "idempotencyKey": false,
826
+ "cli": "tonbo machine secret list"
827
+ },
828
+ "setMachineSecret": {
829
+ "method": "PUT",
830
+ "path": "/machines/{name}/secrets/{key}",
831
+ "pathParams": [
832
+ "name",
833
+ "key"
834
+ ],
835
+ "query": [],
836
+ "idempotencyKey": false,
837
+ "cli": "tonbo machine secret set"
838
+ },
839
+ "unsetMachineSecret": {
840
+ "method": "DELETE",
841
+ "path": "/machines/{name}/secrets/{key}",
842
+ "pathParams": [
843
+ "name",
844
+ "key"
845
+ ],
846
+ "query": [],
847
+ "idempotencyKey": false,
848
+ "cli": "tonbo machine secret unset"
849
+ },
818
850
  "execMachineCommand": {
819
851
  "method": "POST",
820
852
  "path": "/machines/{name}/exec",
@@ -1644,6 +1676,11 @@ var machineTimings = {
1644
1676
  "wakeHoldSeconds": 25,
1645
1677
  "earlyReclaimSeconds": 120
1646
1678
  };
1679
+ var machineSecretLimits = {
1680
+ "perMachine": 64,
1681
+ "valueMaxBytes": 65536,
1682
+ "totalMaxBytes": 262144
1683
+ };
1647
1684
 
1648
1685
  // ../../packages/tonbo-cli-core/dist/src/bearer.js
1649
1686
  var MANAGEMENT_AUDIENCE = cliSessionContract.audiences.management;
@@ -1923,6 +1960,21 @@ var ManagementApi = class {
1923
1960
  listMachines(key, page) {
1924
1961
  return this.page("listMachines", key, page);
1925
1962
  }
1963
+ async listMachineSecrets(key, name) {
1964
+ const answer = await this.request("listMachineSecrets", key, {
1965
+ params: { name }
1966
+ });
1967
+ return answer.data;
1968
+ }
1969
+ setMachineSecret(key, name, secretKey, value) {
1970
+ return this.call("setMachineSecret", key, {
1971
+ params: { name, key: secretKey },
1972
+ body: value
1973
+ });
1974
+ }
1975
+ unsetMachineSecret(key, name, secretKey) {
1976
+ return this.request("unsetMachineSecret", key, { params: { name, key: secretKey } });
1977
+ }
1926
1978
  /**
1927
1979
  * A replay with the same idempotency key returns the Machine the first
1928
1980
  * request created, so a transport failure or a 5xx answer is retried here
@@ -4442,6 +4494,7 @@ import { Help } from "commander";
4442
4494
 
4443
4495
  // src/machine.ts
4444
4496
  import { randomUUID as randomUUID5 } from "node:crypto";
4497
+ import { readFileSync } from "node:fs";
4445
4498
 
4446
4499
  // src/volume.ts
4447
4500
  import { randomUUID as randomUUID4 } from "node:crypto";
@@ -4479,7 +4532,19 @@ function volumeFields(volume) {
4479
4532
  ["Machine", boundText(volume)],
4480
4533
  ["Size", `${volume.size_gib} GiB`],
4481
4534
  ["Live bytes", `${bytes(volume.live_bytes)} (billed)`],
4482
- ["Last commit", volume.last_commit_at ?? "none yet"],
4535
+ ["Last meter report", volume.last_commit_at ?? "none yet"],
4536
+ [
4537
+ "Restored to",
4538
+ volume.restored_to ? `${volume.restored_to.committed_at} (generation ${volume.restored_to.data_generation})` : "none"
4539
+ ],
4540
+ [
4541
+ "Backed up to",
4542
+ volume.backed_up_to ? volume.backed_up_to.committed_at ? `${volume.backed_up_to.committed_at} (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : `empty snapshot (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : "unknown"
4543
+ ],
4544
+ [
4545
+ "Cold GETs",
4546
+ volume.cold ? `${volume.cold.demand_gets} demand, ${volume.cold.hydrate_gets} hydration` : "none"
4547
+ ],
4483
4548
  ["Durability", VOLUME_DURABILITY_LINK],
4484
4549
  ["Created", volume.created_at]
4485
4550
  ]);
@@ -4595,7 +4660,9 @@ var MACHINE_ERROR_HINTS = {
4595
4660
  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.",
4596
4661
  image_failed: "The Machine's image could not be pulled; `tonbo machine show` says why. Fix the image, then name it again with `tonbo machine update --image`.",
4597
4662
  machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
4598
- volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed."
4663
+ volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed.",
4664
+ secret_env_conflict: "Use another secret key or remove the non-secret env variable with that name.",
4665
+ secret_limit: "Reduce the number or byte size of this Machine's secrets."
4599
4666
  };
4600
4667
  var MACHINE_IMAGE_FAILURE_HINTS = {
4601
4668
  image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
@@ -4704,7 +4771,9 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
4704
4771
  meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
4705
4772
  crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
4706
4773
  capacity: "No host had room for its shape; the next request tries again.",
4707
- region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
4774
+ region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it.",
4775
+ budget_slowdown: "Its budget is close to running out, and this wake would pass the budget's slow-down rate: sleep another Machine of the budget, or lower this one's shape, then wake it again. Machines already awake keep running.",
4776
+ budget_exhausted: "Its budget is used up for the month: it wakes after the budget resets, or once a person raises it in the console."
4708
4777
  };
4709
4778
  var MACHINE_ACCOUNT_EGRESS_BLOCKED_HINT = "If this is the free egress cap: 10 GiB a month without purchased credit; buy credit at <billing> to lift it.";
4710
4779
  function conditionText(name, condition, billing) {
@@ -4885,7 +4954,7 @@ function specWarnings(deps, _ports, envKeys) {
4885
4954
  const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
4886
4955
  if (secret.length)
4887
4956
  deps.notice(
4888
- `${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; Machine secrets come later.`
4957
+ `${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; use Machine secrets for private values.`
4889
4958
  );
4890
4959
  }
4891
4960
  function waitTimeout(options) {
@@ -4912,6 +4981,41 @@ async function waitForMachine(deps, key, name, firstWait, every, timeout, settle
4912
4981
  wait = every;
4913
4982
  }
4914
4983
  }
4984
+ function secretFromFile(key, file, delivery) {
4985
+ if (!key || !file) throw new Error("Pass a secret as KEY=FILE.");
4986
+ const bytes2 = readFileSync(file === "-" ? 0 : file);
4987
+ if (bytes2.byteLength > machineSecretLimits.valueMaxBytes)
4988
+ throw new Error(`Secret ${key} exceeds the ${machineSecretLimits.valueMaxBytes}-byte limit.`);
4989
+ if (delivery === "file") return { delivery, value_base64: bytes2.toString("base64") };
4990
+ let value;
4991
+ try {
4992
+ value = new TextDecoder("utf-8", { fatal: true }).decode(bytes2);
4993
+ } catch {
4994
+ throw new Error(`Secret ${key} is not UTF-8; use --secret-file for binary bytes.`);
4995
+ }
4996
+ return { delivery, value };
4997
+ }
4998
+ function createSecrets(options) {
4999
+ const values = {};
5000
+ let total = 0;
5001
+ for (const [delivery, entries] of [
5002
+ ["env", options.secretEnv],
5003
+ ["file", options.secretFile]
5004
+ ]) {
5005
+ for (const entry of entries ?? []) {
5006
+ const separator = entry.indexOf("=");
5007
+ if (separator < 1) throw new Error("Pass a secret as KEY=FILE.");
5008
+ const key = entry.slice(0, separator);
5009
+ if (Object.hasOwn(values, key)) throw new Error(`Secret ${key} was named twice.`);
5010
+ const secret = secretFromFile(key, entry.slice(separator + 1), delivery);
5011
+ total += "value" in secret ? Buffer.byteLength(secret.value, "utf8") : Buffer.from(secret.value_base64, "base64").byteLength;
5012
+ if (Object.keys(values).length >= machineSecretLimits.perMachine || total > machineSecretLimits.totalMaxBytes)
5013
+ throw new Error("Machine secrets exceed their count or total byte limit.");
5014
+ values[key] = secret;
5015
+ }
5016
+ }
5017
+ return values;
5018
+ }
4915
5019
  function deleteAfterSeconds(option) {
4916
5020
  if (typeof option === "number") return option;
4917
5021
  return option === false ? null : void 0;
@@ -4964,6 +5068,50 @@ async function machineListCommand(deps, options = {}) {
4964
5068
  );
4965
5069
  });
4966
5070
  }
5071
+ async function machineSecretListCommand(deps, name, options = {}) {
5072
+ await machineCommand(async () => {
5073
+ const secrets = await deps.api.listMachineSecrets(await deps.auth.apiKey(), name);
5074
+ print(
5075
+ deps,
5076
+ options.json,
5077
+ secrets,
5078
+ secrets.length ? table(
5079
+ ["KEY", "DELIVERY", "VERSION", "UPDATED", "BY"],
5080
+ secrets.map((secret) => [
5081
+ secret.key,
5082
+ secret.delivery,
5083
+ String(secret.version),
5084
+ secret.updated_at,
5085
+ secret.updated_by.sub
5086
+ ])
5087
+ ) : `Machine ${name} has no secrets.`
5088
+ );
5089
+ });
5090
+ }
5091
+ async function machineSecretSetCommand(deps, name, secretKey, options) {
5092
+ await machineCommand(async () => {
5093
+ const entry = await deps.api.setMachineSecret(
5094
+ await deps.auth.apiKey(),
5095
+ name,
5096
+ secretKey,
5097
+ secretFromFile(secretKey, options.from, options.delivery ?? "env")
5098
+ );
5099
+ print(
5100
+ deps,
5101
+ options.json,
5102
+ entry,
5103
+ `Set ${entry.key} on Machine ${name} (version ${entry.version}; applies at the next activation).`
5104
+ );
5105
+ });
5106
+ }
5107
+ async function machineSecretUnsetCommand(deps, name, secretKey) {
5108
+ await machineCommand(async () => {
5109
+ await deps.api.unsetMachineSecret(await deps.auth.apiKey(), name, secretKey);
5110
+ deps.output(
5111
+ `Removed ${secretKey} from Machine ${name}; the next activation will not receive it.`
5112
+ );
5113
+ });
5114
+ }
4967
5115
  function conditionsCell(machine) {
4968
5116
  const conditions = machine.status.conditions ?? [];
4969
5117
  if (!conditions.length) return "-";
@@ -5046,6 +5194,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
5046
5194
  if (!options.image) throw new Error("Pass --image <reference>.");
5047
5195
  const timeout = waitTimeout(options);
5048
5196
  const env = parseEnv(options.env ?? []);
5197
+ const secrets = createSecrets(options);
5049
5198
  const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
5050
5199
  const key = await deps.auth.apiKey();
5051
5200
  const bound = typeof options.volume === "string" ? await resolveVolumeBinding(deps, key, options.volume) : null;
@@ -5068,6 +5217,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
5068
5217
  command: command.length ? command : void 0,
5069
5218
  ports: ports.length ? ports : void 0,
5070
5219
  env: Object.keys(env).length ? env : void 0,
5220
+ ...Object.keys(secrets).length ? { secrets } : {},
5071
5221
  sleep_after_seconds: options.sleepAfter,
5072
5222
  idle_action: options.idleAction,
5073
5223
  stop_grace_seconds: options.stopGrace,
@@ -5713,7 +5863,7 @@ var COMMAND_HELP = {
5713
5863
  "tonbo machine create": {
5714
5864
  about: [
5715
5865
  "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`.",
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.",
5866
+ "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. `--secret-env KEY=FILE` and `--secret-file KEY=FILE` read initial secrets from local files and store them with the Machine before its first boot; their values are never returned. 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.",
5717
5867
  "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."
5718
5868
  ],
5719
5869
  examples: ["tonbo machine create web --image nginx:1.27 --port 80:public --wait"],
@@ -5738,6 +5888,28 @@ var COMMAND_HELP = {
5738
5888
  examples: ["tonbo machine show web"],
5739
5889
  arguments: NAME("Machine")
5740
5890
  },
5891
+ "tonbo machine secret list": {
5892
+ about: [
5893
+ "Lists a Machine's secret keys, delivery, version and last writer. The values are never returned."
5894
+ ],
5895
+ examples: ["tonbo machine secret list web"],
5896
+ arguments: { name: "the Machine whose secrets to list" }
5897
+ },
5898
+ "tonbo machine secret set": {
5899
+ about: [
5900
+ "Reads the secret from a local file, or standard input with `--from -`, without putting its value in shell history. `env` delivers UTF-8 text as an environment variable; `file` delivers arbitrary bytes under `$TONBO_SECRETS_DIR`. The new value applies at the next activation."
5901
+ ],
5902
+ examples: ["tonbo machine secret set web API_KEY --from ./api-key.txt"],
5903
+ arguments: { name: "the Machine to change", key: "the secret's environment or file name" },
5904
+ defaults: { "--delivery": "env" }
5905
+ },
5906
+ "tonbo machine secret unset": {
5907
+ about: [
5908
+ "Removes a Machine secret whether or not it exists. A running activation keeps its existing value until the next activation."
5909
+ ],
5910
+ examples: ["tonbo machine secret unset web API_KEY"],
5911
+ arguments: { name: "the Machine to change", key: "the secret to remove" }
5912
+ },
5741
5913
  "tonbo machine update": {
5742
5914
  about: [
5743
5915
  "Changes only what its options name. It reads the Machine and sends the ETag it read, or `--if-match`'s, so a change someone made in between is refused with `precondition_failed` rather than overwritten; a Machine whose creation failed is refused with `machine_failed`. `--env` and `--unset-env` change single variables; `--port` replaces every port, and the current default port stays the default while it is still listed.",
@@ -6526,7 +6698,7 @@ async function sshCertificateIssueCommand(deps, options) {
6526
6698
 
6527
6699
  // src/app.ts
6528
6700
  var packageVersion = JSON.parse(
6529
- readFileSync(new URL("../../package.json", import.meta.url), "utf8")
6701
+ readFileSync2(new URL("../../package.json", import.meta.url), "utf8")
6530
6702
  );
6531
6703
  function createDependencies(selection = {}) {
6532
6704
  const api = apiOrigin();
@@ -6900,6 +7072,10 @@ function createProgram(makeDependencies = createDependencies) {
6900
7072
  ).option("--no-database", "bind no database (the default)"),
6901
7073
  false
6902
7074
  ).option(
7075
+ "--secret-env <KEY=FILE>",
7076
+ "read a UTF-8 environment secret from FILE; repeat",
7077
+ repeated
7078
+ ).option("--secret-file <KEY=FILE>", "read binary file secret from FILE; repeat", repeated).option(
6903
7079
  "--idempotency-key <key>",
6904
7080
  "reuse the key of an unconfirmed creation to get the Machine it created"
6905
7081
  ).option("--wait", "wait until the Machine is active, or say why it failed").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
@@ -6913,6 +7089,29 @@ function createProgram(makeDependencies = createDependencies) {
6913
7089
  ).action(
6914
7090
  async (name, options) => machineShowCommand(dependencies(), name, options)
6915
7091
  );
7092
+ const machineSecret = machine.command("secret").description("manage a Machine's write-only secrets");
7093
+ operationOptions(
7094
+ machineSecret.command("list <name>").description("list secret keys and delivery, never values"),
7095
+ "listMachineSecrets"
7096
+ ).action(
7097
+ async (name, options) => machineSecretListCommand(dependencies(), name, options)
7098
+ );
7099
+ operationOptions(
7100
+ machineSecret.command("set <name> <key>").description(
7101
+ "set a secret from a local file or standard input; applies at the next activation"
7102
+ ).requiredOption("--from <file>", "read the value from FILE, or - for standard input").addOption(
7103
+ new Option("--delivery <mode>", "env or file (binary)").choices(["env", "file"]).default("env")
7104
+ ),
7105
+ "setMachineSecret"
7106
+ ).action(
7107
+ async (name, key, options) => machineSecretSetCommand(dependencies(), name, key, options)
7108
+ );
7109
+ operationOptions(
7110
+ machineSecret.command("unset <name> <key>").description("remove a secret at the next activation"),
7111
+ "unsetMachineSecret"
7112
+ ).action(
7113
+ async (name, key) => machineSecretUnsetCommand(dependencies(), name, key)
7114
+ );
6916
7115
  operationOptions(
6917
7116
  machineSpecOptions(
6918
7117
  machine.command("update <name> [command...]").description(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.14.0",
3
+ "version": "0.14.1",
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": {