@tonbo/cli 0.7.0 → 0.7.2

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 (3) hide show
  1. package/README.md +33 -13
  2. package/dist/bin/tonbo.js +475 -68
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -14,7 +14,7 @@ tonbo login [--account <name>]
14
14
  tonbo logout
15
15
  ```
16
16
 
17
- `login` opens the browser at `https://tonbo.dev/cli/login`, where you pick the account (your personal account unless `--account` names another) and approve a personal API key named `CLI on <hostname>`. The browser returns a one-time code to a local callback on `127.0.0.1`, and the CLI redeems it with its PKCE verifier for the key. The key acts as you with your role in that account, expires after six months and replaces the key an earlier login on the same host created; there is no refresh, so an expired or revoked key asks you to run `tonbo login` again. One login holds one account: log in again to switch accounts.
17
+ `login` prints the login URL and opens the browser at `https://tonbo.dev/cli/login`, where you pick the account (your personal account unless `--account` names another) and approve a personal API key named `CLI on <hostname>`. The browser returns a one-time code to a local callback on `127.0.0.1`, and the CLI redeems it with its PKCE verifier for the key. The key acts as you with your role in that account, expires after six months and replaces the key an earlier login on the same host created; there is no refresh, so an expired or revoked key asks you to run `tonbo login` again. One login holds one account: log in again to switch accounts. When no browser can be opened (an SSH session, a server without a desktop), the CLI keeps waiting: open the printed URL in a browser that can reach the callback's `127.0.0.1:<port>` on that machine, through `ssh -L <port>:127.0.0.1:<port>` or the VM's port forwarding.
18
18
 
19
19
  The login and the credentials file are shared with [`yield`](../yield) through `@tonbo/cli-core`: either CLI's `login` signs both in, and either `logout` signs both out. The login is saved at `${XDG_CONFIG_HOME:-$HOME/.config}/tonbo/credentials.json` as `{version, account_id, key_id, key, expires_at}`. The CLI atomically replaces that file with owner-only permissions and refuses a symlink or a file other users can read. A file written by an earlier CLI release is not migrated; run `tonbo login`.
20
20
 
@@ -37,7 +37,7 @@ Every command other than `login` and `logout` calls one operation of the [Manage
37
37
  | `tonbo member update <member> [--role <role>] [--name <name>]` | `updateMember` | `PATCH /v1/members/{subject}` |
38
38
  | `tonbo agent create <name> [--role <role>] [--region <region>] [--idempotency-key <key>]` | `createAgent` | `POST /v1/agents` |
39
39
  | `tonbo api-key list [--limit <n>] [--cursor <c>]` | `listApiKeys` | `GET /v1/api-keys` |
40
- | `tonbo api-key create <name> [--member <member>] [--read-only] [--bucket <name>]… [--database <name>]… [--database-prefix <prefix>]… [--expires-in <period>]` | `createApiKey` | `POST /v1/api-keys` |
40
+ | `tonbo api-key create <name> [--member <member>] [--read-only] [--bucket <name>]… [--database <name>]… [--database-prefix <prefix>]… [--stream <name>]… [--stream-prefix <prefix>]… [--expires-in <period>]` | `createApiKey` | `POST /v1/api-keys` |
41
41
  | `tonbo api-key show <key-id>` | `getApiKey` | `GET /v1/api-keys/{key_id}` |
42
42
  | `tonbo api-key revoke <key-id> [--yes]` | `revokeApiKey` | `DELETE /v1/api-keys/{key_id}` |
43
43
  | `tonbo bucket list [--limit <n>] [--cursor <c>]` | `listBuckets` | `GET /v1/buckets` |
@@ -46,11 +46,17 @@ Every command other than `login` and `logout` calls one operation of the [Manage
46
46
  | `tonbo bucket update <name> (--public \| --private)` | `updateBucket` | `PATCH /v1/buckets/{name}` |
47
47
  | `tonbo bucket delete <name> [--yes]` | `deleteBucket` | `DELETE /v1/buckets/{name}` |
48
48
  | `tonbo database list [--prefix <prefix>] [--limit <n>] [--cursor <c>]` | `listDatabases` | `GET /v1/databases` |
49
- | `tonbo database create <name> [--region <region>] [--idempotency-key <key>]` | `createDatabase` | `POST /v1/databases` |
49
+ | `tonbo database create <name> [--region <region>] [--expires-at <time> \| --expires-in <duration>] [--idempotency-key <key>]` | `createDatabase` | `POST /v1/databases` |
50
50
  | `tonbo database show <name>` | `getDatabase` | `GET /v1/databases/{name}` |
51
51
  | `tonbo database update <name> (--public \| --private)` | `updateDatabase` | `PATCH /v1/databases/{name}` |
52
52
  | `tonbo database delete <name> [--yes]` | `deleteDatabase` | `DELETE /v1/databases/{name}` |
53
53
  | `tonbo database usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--names <a,b>] [--limit <n>] [--cursor <c>]` | `listDatabaseUsage` | `GET /v1/usage/databases` |
54
+ | `tonbo stream list [--prefix <prefix>] [--limit <n>] [--cursor <c>]` | `listStreams` | `GET /v1/streams` |
55
+ | `tonbo stream create <name> [--region <region>] [--content-type <type>] [--expires-at <time> \| --expires-in <duration>] [--idempotency-key <key>]` | `createStream` | `POST /v1/streams` |
56
+ | `tonbo stream show <name>` | `getStream` | `GET /v1/streams/{name}` |
57
+ | `tonbo stream update <name> (--public \| --private)` | `updateStream` | `PATCH /v1/streams/{name}` |
58
+ | `tonbo stream delete <name> [--yes]` | `deleteStream` | `DELETE /v1/streams/{name}` |
59
+ | `tonbo stream usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--names <a,b>] [--limit <n>] [--cursor <c>]` | `listStreamUsage` | `GET /v1/usage/streams` |
54
60
  | `tonbo usage show [--period <YYYY-MM>]` | `getUsage` | `GET /v1/usage` |
55
61
  | `tonbo machine list [--limit <n>] [--cursor <c>]` | `listMachines` | `GET /v1/machines` |
56
62
  | `tonbo machine create <name> --image <reference> [--shape <shape>] [--region <region>] [--port <port[:access]>]… [--default-port <port>] [--env <KEY=VALUE>]… [--sleep-after <seconds>] [--idle-action <action>] [--stop-grace <seconds>] [--idempotency-key <key>] [-- <command>…]` | `createMachine` | `POST /v1/machines` |
@@ -62,17 +68,17 @@ Every command other than `login` and `logout` calls one operation of the [Manage
62
68
 
63
69
  ### Output and errors
64
70
 
65
- Every command prints an aligned table or a list of fields for people. With `--json` it prints the answer's `data` exactly as the API returned it, one JSON value on stdout and nothing else there. A list prints one page; when there is another, the CLI writes `More results: run again with --cursor <cursor>` to stderr, so `--json` output stays the API's `data`. `database usage --json` and `machine shapes --json` print the whole answer, since the usage's `other` row, checkpoint and prices, and the default shape, belong with the rows. `--limit` asks for up to that many items (the API's default is 50, its maximum 200).
71
+ Every command prints an aligned table or a list of fields for people. With `--json` it prints the answer's `data` exactly as the API returned it, one JSON value on stdout and nothing else there. A list prints one page; when there is another, the CLI writes `More results: run again with --cursor <cursor>` to stderr, so `--json` output stays the API's `data`. `database usage --json`, `stream usage --json` and `machine shapes --json` print the whole answer, since the usage's `other` row, checkpoint and prices, and the default shape, belong with the rows. `--limit` asks for up to that many items (the API's default is 50, its maximum 200).
66
72
 
67
73
  A refusal prints the problem's detail, the fields a validation error names, and the request's `X-Request-Id` to stderr, and the command exits with status 1. An invalid or expired key adds `Run tonbo login`.
68
74
 
69
75
  ### Retried creations
70
76
 
71
- `agent create`, `bucket create`, `database create` and `machine create` send an `Idempotency-Key`, a new UUID unless `--idempotency-key` names one. When a creation is not confirmed (the network failed or the server answered 5xx), the CLI prints its key; retrying with that key and the same arguments returns what the first request created instead of creating a second one. `api-key create` has no idempotency key, because a key's secret is never stored and a replay could not return it; a live key's name is unique, so a blind retry is refused with 409 rather than creating a second key.
77
+ `agent create`, `bucket create`, `database create`, `stream create` and `machine create` send an `Idempotency-Key`, a new UUID unless `--idempotency-key` names one. When a creation is not confirmed (the network failed or the server answered 5xx), the CLI prints its key; retrying with that key and the same arguments returns what the first request created instead of creating a second one. `api-key create` has no idempotency key, because a key's secret is never stored and a replay could not return it; a live key's name is unique, so a blind retry is refused with 409 rather than creating a second key.
72
78
 
73
79
  ### Destructive commands
74
80
 
75
- `api-key revoke`, `bucket delete`, `database delete` and `machine delete` ask before they act when stdin and stderr are a terminal: a revocation is confirmed by typing `yes`, a deletion by typing the bucket's, database's or Machine's name, as in the console. Without a terminal they refuse unless `--yes` is given.
81
+ `api-key revoke`, `bucket delete`, `database delete`, `stream delete` and `machine delete` ask before they act when stdin and stderr are a terminal: a revocation is confirmed by typing `yes`, a deletion by typing the bucket's, database's, stream's or Machine's name, as in the console. Without a terminal they refuse unless `--yes` is given.
76
82
 
77
83
  ## Account
78
84
 
@@ -103,7 +109,7 @@ tonbo api-key create ci-reader --member pi-live --read-only --bucket events --ex
103
109
  tonbo api-key revoke 70000000-0000-4000-8000-000000000007
104
110
  ```
105
111
 
106
- `api-key list` shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret. `api-key create` creates a personal key for you, or with `--member` a key of an Agent (its subject, id or name; owners and admins only). `--read-only`, and either `--bucket` or `--database` and `--database-prefix` (repeat each for several), narrow the key for its whole life; `--expires-in` is one of `15_days`, `1_month`, `6_months`, `1_year` or `never` (the default). The secret is printed once, in that answer only, with a warning on stderr: store it then, because no command can show it again. Without `--json`, the answer also carries a ready-to-paste `TONBO_API_KEY=<secret>` line on stdout, beside the secret, for your application's environment (a `.env` file or a secret store), and stderr notes that `TONBO_API_KEY` in your own shell overrides your `tonbo login`, for this CLI too. stderr never carries the secret, and `--json` prints only the answer. `api-key show` prints one key, and `api-key revoke` revokes it and every token minted from it.
112
+ `api-key list` shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret. `api-key create` creates a personal key for you, or with `--member` a key of an Agent (its subject, id or name; owners and admins only). `--read-only`, and either `--bucket` or any of `--database`, `--database-prefix`, `--stream` and `--stream-prefix` (repeat each for several), narrow the key for its whole life; a key narrowed to databases and streams reaches both, and `api-key list` and `api-key show` print what each key reaches of each; `--expires-in` is one of `15_days`, `1_month`, `6_months`, `1_year` or `never` (the default). The secret is printed once, in that answer only, with a warning on stderr: store it then, because no command can show it again. Without `--json`, the answer also carries a ready-to-paste `TONBO_API_KEY=<secret>` line on stdout, beside the secret, for your application's environment (a `.env` file or a secret store), and stderr notes that `TONBO_API_KEY` in your own shell overrides your `tonbo login`, for this CLI too. stderr never carries the secret, and `--json` prints only the answer. `api-key show` prints one key, and `api-key revoke` revokes it and every token minted from it.
107
113
 
108
114
  ## Buckets
109
115
 
@@ -119,32 +125,46 @@ tonbo bucket delete events
119
125
 
120
126
  ```console
121
127
  tonbo database create notes
128
+ tonbo database create scratch --expires-in 24h
122
129
  tonbo database show notes
123
130
  ```
124
131
 
125
- `database create` creates a database in `--region`, or in the default region, and prints its fields with its URL. Without `--json` it then writes the next step to stderr, opening the database by name from code:
132
+ `database create` creates a database in `--region`, or in the default region, and prints its fields with its URL. `--expires-at` and `--expires-in` give it an expiry fixed for its life, by the same rules as `stream create`'s: once it passes, Tonbo deletes the database within a minute, as `database delete` would, and its name is free again; `database show` prints it, and without either the database never expires. A Machine's database never expires. Without `--json` it then writes the next step to stderr, opening the database by name from code:
126
133
 
127
134
  ```text
128
- Next: npm i @tonbo/sqlite-ursula
135
+ Next: npm i --save-exact @tonbo/sqlite-ursula@0.11.2
129
136
  open("notes") from "@tonbo/sqlite-ursula/tonbo"
130
137
  ```
131
138
 
132
139
  `open("notes")` resolves the database through `GET /v1/databases/notes`. It takes its key from, in order, its `apiKey` option, then `TONBO_API_KEY`, then the `tonbo login` credentials file (`key_sources` in `contracts/cli/credentials-v1.json`). A key narrowed to databases reads the databases it reaches there, so an application can be given a narrowed key.
133
140
 
141
+ ## Streams
142
+
143
+ ```console
144
+ tonbo stream create events --content-type application/json
145
+ tonbo stream create run-42 --content-type application/json --expires-in 24h
146
+ tonbo stream show events
147
+ tonbo api-key create run-reader --read-only --stream-prefix run-
148
+ ```
149
+
150
+ `stream create` creates a raw durable stream in `--region`, or in the default region, with the content type every append must send (`--content-type`, `application/octet-stream` by default; one lowercase `type/subtype` without parameters), and prints its fields with its URL on the region's Streams origin. A stream and a database may share a name; each kind has its own names, limit and usage (`stream usage`, whose `other` holds the account's databases and everything else, as `database usage`'s holds its streams). Without `--json` it then writes the next step to stderr: append with `POST` to the URL and read with `GET` from `?offset=-1`, with any Durable Streams client. `--expires-at` (an RFC 3339 time, at least 60 seconds after the creation) or `--expires-in` (`90s` or more, such as `30m`, `24h` or `7d`, counted from now) gives the stream an expiry fixed for its life: once it passes, Tonbo deletes the stream within a minute, as `stream delete` would, and its name is free again; `stream show` prints it, and without either the stream never expires. `--expires-in` is counted from when the command runs, so it never goes with `--idempotency-key`: an unconfirmed creation with an expiry prints a retry hint naming its key and the absolute time it sent, and the retry passes that time with `--expires-at` (the same for `database create`). A key narrowed to streams (`api-key create --stream` or `--stream-prefix`) reaches only those streams; a key narrowed to buckets, or by name to databases alone, reaches no stream.
151
+
134
152
  ## Machines
135
153
 
136
154
  ```console
137
155
  tonbo machine shapes
138
- tonbo machine create web --image ghcr.io/acme/web:1.4 --port 8080:public --env MODE=prod
139
- tonbo machine create worker --image python:3.13 -- python -m worker
156
+ tonbo machine create web --image tonbo.dev/platform --port 8080:public --env MODE=prod
157
+ tonbo machine create worker --image tonbo.dev/platform -- sh -c 'while true; do date; sleep 60; done'
140
158
  tonbo machine update web --env MODE=staging --sleep-after 300
141
159
  tonbo machine sleep web
142
160
  tonbo machine delete web
143
161
  ```
144
162
 
145
- A Machine is a VM booted from a public OCI image with a linux/arm64 variant, in one region, with its own database (`contracts/machines/machine-v2.json`). Machines open by invitation: until a region is open for them, every `tonbo machine` command is refused with `region_unavailable`, and the CLI says that nothing was created or changed.
163
+ A Machine is a VM booted from an image, in one region, with its own database (`contracts/machines/machine-v2.json`). Machines open by invitation: until a region is open for them, every `tonbo machine` command is refused with `region_unavailable`, and the CLI says that nothing was created or changed.
164
+
165
+ The image is `tonbo.dev/platform`, the platform image each node release ships, or, once registry images are offered, a public OCI image with a linux/arm64 variant. Until then any other reference is refused with `image_not_offered`, before anything is created or changed; `machine show` prints the platform image as `tonbo.dev/platform (platform image)`.
146
166
 
147
- `machine create` resolves the image to its linux/arm64 digest and reserves the Machine's name and its hostnames: `https://<name>-<account>.<region>.tonbo.sh` for the default port, and `<name>.<account>@<region>.tonbo.sh` for SSH. Without `--shape` it takes the shape `machine shapes` marks as the default. `--port <port>` serves a private port (a member's token or SSH) and `--port <port>:public` an open one, which needs the `machines.public` capability; the first port is served at the hostname unless `--default-port` names another. A command after `--` replaces the image's CMD. The answer is the Machine while it is `creating`; `machine show` reports it `active` when it is ready.
167
+ `machine create` resolves a registry image to its linux/arm64 digest (the platform image resolves to none) and reserves the Machine's name and its hostnames: `https://<name>-<account>.<region>.tonbo.sh` for the default port, and `<name>.<account>@<region>.tonbo.sh` for SSH. Without `--shape` it takes the shape `machine shapes` marks as the default. `--port <port>` serves a private port (a member's token or SSH) and `--port <port>:public` an open one, which needs the `machines.public` capability; the first port is served at the hostname unless `--default-port` names another. A command after `--` replaces the image's CMD. The answer is the Machine while it is `creating`; `machine show` reports it `active` when it is ready.
148
168
 
149
169
  `machine update` changes only what its options name. It reads the Machine and writes the change at the revision it read, or at `--revision`, so a change someone made in between is refused with `revision_mismatch` rather than overwritten. `--env` and `--unset-env` change single variables; `--port` replaces every port, naming a public port again keeps its limits, and the current default port stays the default while it is still listed unless `--default-port` names another. Ports and `--sleep-after` apply at once; image, command, shape and env at the Machine's next boot. `machine sleep` puts an awake Machine to sleep now; its next connection wakes it. `machine delete` deletes the Machine with its disk, database and logs, and its name is free for a new Machine at once.
150
170
 
package/dist/bin/tonbo.js CHANGED
@@ -617,6 +617,71 @@ var managementOperations = {
617
617
  "query": [],
618
618
  "idempotencyKey": false,
619
619
  "cli": "tonbo machine shapes"
620
+ },
621
+ "listStreams": {
622
+ "method": "GET",
623
+ "path": "/streams",
624
+ "pathParams": [],
625
+ "query": [
626
+ "prefix",
627
+ "cursor",
628
+ "limit"
629
+ ],
630
+ "idempotencyKey": false,
631
+ "cli": "tonbo stream list"
632
+ },
633
+ "createStream": {
634
+ "method": "POST",
635
+ "path": "/streams",
636
+ "pathParams": [],
637
+ "query": [],
638
+ "idempotencyKey": true,
639
+ "cli": "tonbo stream create"
640
+ },
641
+ "getStream": {
642
+ "method": "GET",
643
+ "path": "/streams/{name}",
644
+ "pathParams": [
645
+ "name"
646
+ ],
647
+ "query": [],
648
+ "idempotencyKey": false,
649
+ "cli": "tonbo stream show"
650
+ },
651
+ "updateStream": {
652
+ "method": "PATCH",
653
+ "path": "/streams/{name}",
654
+ "pathParams": [
655
+ "name"
656
+ ],
657
+ "query": [],
658
+ "idempotencyKey": false,
659
+ "cli": "tonbo stream update"
660
+ },
661
+ "deleteStream": {
662
+ "method": "DELETE",
663
+ "path": "/streams/{name}",
664
+ "pathParams": [
665
+ "name"
666
+ ],
667
+ "query": [],
668
+ "idempotencyKey": false,
669
+ "cli": "tonbo stream delete"
670
+ },
671
+ "listStreamUsage": {
672
+ "method": "GET",
673
+ "path": "/usage/streams",
674
+ "pathParams": [],
675
+ "query": [
676
+ "period",
677
+ "sort",
678
+ "prefix",
679
+ "names",
680
+ "cursor",
681
+ "limit"
682
+ ],
683
+ "idempotencyKey": false,
684
+ "cli": "tonbo stream usage"
620
685
  }
621
686
  };
622
687
  var accountRoles = [
@@ -960,7 +1025,7 @@ var AuthClient = class {
960
1025
  hostname: this.hostname,
961
1026
  ...account ? { account } : {}
962
1027
  }).toString();
963
- return this.browserOpener(authorization.toString());
1028
+ return authorization.toString();
964
1029
  }, progress);
965
1030
  progress({ status: "started", step: "key-redemption" });
966
1031
  const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
@@ -981,16 +1046,17 @@ var AuthClient = class {
981
1046
  progress({ status: "completed", step: "credential-store" });
982
1047
  return login;
983
1048
  }
984
- listenForCode(expectedState, ready, progress) {
1049
+ listenForCode(expectedState, authorizationUrl, progress) {
985
1050
  return new Promise((resolve, reject) => {
986
1051
  let settled = false;
987
1052
  const sockets = /* @__PURE__ */ new Set();
988
1053
  let authorizationStarted = false;
989
- const startAuthorization = () => {
1054
+ const startAuthorization = (browserOpened = true) => {
990
1055
  if (authorizationStarted)
991
1056
  return;
992
1057
  authorizationStarted = true;
993
- progress({ status: "completed", step: "browser" });
1058
+ if (browserOpened)
1059
+ progress({ status: "completed", step: "browser" });
994
1060
  progress({ status: "started", step: "browser-authorization" });
995
1061
  };
996
1062
  const finish = (result, completedResponseSocket) => {
@@ -1054,10 +1120,20 @@ var AuthClient = class {
1054
1120
  server.listen(0, LOOPBACK_HOST, () => {
1055
1121
  const { port } = server.address();
1056
1122
  progress({ status: "completed", step: "callback-server" });
1123
+ const url = authorizationUrl(`http://${LOOPBACK_HOST}:${port}/callback`);
1124
+ progress({ status: "authorization-url", step: "browser", url });
1057
1125
  progress({ status: "started", step: "browser" });
1058
- void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
1059
- error: error instanceof Error ? error : new Error("Could not open the login browser.")
1060
- }));
1126
+ void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
1127
+ if (settled || authorizationStarted)
1128
+ return;
1129
+ progress({
1130
+ status: "failed",
1131
+ step: "browser",
1132
+ reason: error instanceof Error ? error.message : String(error),
1133
+ callbackPort: port
1134
+ });
1135
+ startAuthorization(false);
1136
+ });
1061
1137
  });
1062
1138
  });
1063
1139
  }
@@ -1069,12 +1145,27 @@ function respond(response, outcome, finished) {
1069
1145
  response.writeHead(page.status, { ...page.headers, connection: "close" });
1070
1146
  response.end(page.body);
1071
1147
  }
1148
+ function browserCommand(url, platform = process.platform, env = process.env) {
1149
+ if (platform === "darwin")
1150
+ return ["open", [url]];
1151
+ if (platform === "win32")
1152
+ return ["rundll32", ["url.dll,FileProtocolHandler", url]];
1153
+ if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
1154
+ throw new Error("there is no graphical session (DISPLAY and WAYLAND_DISPLAY are unset)");
1155
+ return ["xdg-open", [url]];
1156
+ }
1072
1157
  async function openBrowser(url) {
1073
- if (process.platform === "darwin")
1074
- return void await exec("open", [url]);
1075
- if (process.platform === "win32")
1076
- return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
1077
- return void await exec("xdg-open", [url]);
1158
+ const [command, args] = browserCommand(url);
1159
+ try {
1160
+ await exec(command, args);
1161
+ } catch (error) {
1162
+ const code = error.code;
1163
+ if (code === "ENOENT")
1164
+ throw new Error(`${command} is not installed`, { cause: error });
1165
+ if (typeof code === "number")
1166
+ throw new Error(`${command} exited with code ${code}`, { cause: error });
1167
+ throw error;
1168
+ }
1078
1169
  }
1079
1170
 
1080
1171
  // ../../packages/tonbo-cli-core/dist/src/api.js
@@ -1201,7 +1292,7 @@ var ManagementApi = class {
1201
1292
  return this.call("createDatabase", key, {
1202
1293
  body: withoutUndefined(request),
1203
1294
  idempotencyKey
1204
- }).catch(unconfirmed("Database creation", idempotencyKey, "the same name and region"));
1295
+ }).catch(unconfirmed("Database creation", idempotencyKey, request.expires_at ? `the same name, region and --expires-at ${request.expires_at}` : "the same name and region"));
1205
1296
  }
1206
1297
  getDatabase(key, name) {
1207
1298
  return this.call("getDatabase", key, { params: { name } });
@@ -1216,6 +1307,30 @@ var ManagementApi = class {
1216
1307
  listDatabaseUsage(key, page, query = {}) {
1217
1308
  return this.page("listDatabaseUsage", key, page, query);
1218
1309
  }
1310
+ listStreams(key, page, prefix) {
1311
+ return this.page("listStreams", key, page, { prefix });
1312
+ }
1313
+ /** A replay with the same idempotency key returns the stream the first
1314
+ * request created. */
1315
+ createStream(key, request, idempotencyKey) {
1316
+ return this.call("createStream", key, {
1317
+ body: withoutUndefined(request),
1318
+ idempotencyKey
1319
+ }).catch(unconfirmed("Stream creation", idempotencyKey, request.expires_at ? `the same name, region, content type and --expires-at ${request.expires_at}` : "the same name, region and content type"));
1320
+ }
1321
+ getStream(key, name) {
1322
+ return this.call("getStream", key, { params: { name } });
1323
+ }
1324
+ updateStream(key, name, change) {
1325
+ return this.call("updateStream", key, { params: { name }, body: change });
1326
+ }
1327
+ deleteStream(key, name) {
1328
+ return this.call("deleteStream", key, { params: { name } });
1329
+ }
1330
+ /** The whole usage page: its rows, `other`, prices and checkpoint. */
1331
+ listStreamUsage(key, page, query = {}) {
1332
+ return this.page("listStreamUsage", key, page, query);
1333
+ }
1219
1334
  getUsage(key, period) {
1220
1335
  return this.call("getUsage", key, { query: { period } });
1221
1336
  }
@@ -1344,6 +1459,16 @@ var LOGIN_PROGRESS_MESSAGES = {
1344
1459
  }
1345
1460
  };
1346
1461
  function reportLoginProgress(progress, event) {
1462
+ if (event.status === "authorization-url") {
1463
+ progress.note(`Open this URL to approve the login: ${event.url}`);
1464
+ return;
1465
+ }
1466
+ if (event.status === "failed") {
1467
+ const port = event.callbackPort;
1468
+ progress.fail(`Could not open a browser: ${event.reason.replace(/\.$/, "")}.`);
1469
+ progress.note(`Open the URL above in a browser that can reach 127.0.0.1:${port} on this machine. From another computer, forward that port first: \`ssh -L ${port}:127.0.0.1:${port} <this host>\` or your VM's port forwarding both work.`);
1470
+ return;
1471
+ }
1347
1472
  const messages = LOGIN_PROGRESS_MESSAGES[event.step];
1348
1473
  if (event.status === "started")
1349
1474
  progress.start(messages.started);
@@ -1429,6 +1554,17 @@ var TerminalProgress = class {
1429
1554
  fail(message = this.activeMessage) {
1430
1555
  this.finish("!!", message);
1431
1556
  }
1557
+ note(message) {
1558
+ if (this.stream.isTTY && this.activeMessage) {
1559
+ this.stream.write(`\r${"".padEnd(this.lastWidth)}\r${message}
1560
+ `);
1561
+ this.lastWidth = 0;
1562
+ this.render(`[${FRAMES[this.frame]}] ${this.activeMessage}`);
1563
+ return;
1564
+ }
1565
+ this.stream.write(`${message}
1566
+ `);
1567
+ }
1432
1568
  finish(marker, message) {
1433
1569
  if (this.timer)
1434
1570
  clearInterval(this.timer);
@@ -1611,19 +1747,23 @@ async function memberUpdateCommand(deps, member, options = {}) {
1611
1747
  function keyAccess(apiKey) {
1612
1748
  return apiKey.read_only ? "read-only" : "read-write";
1613
1749
  }
1614
- function narrowedByDatabase(apiKey) {
1615
- return apiKey.databases.length + apiKey.database_prefixes.length > 0;
1750
+ function narrowedByName(apiKey) {
1751
+ return apiKey.databases.length + apiKey.database_prefixes.length + apiKey.streams.length + apiKey.stream_prefixes.length > 0;
1616
1752
  }
1617
1753
  function keyBuckets(apiKey) {
1618
1754
  if (apiKey.buckets.length) return apiKey.buckets.join(", ");
1619
- return narrowedByDatabase(apiKey) ? "none" : "all";
1755
+ return narrowedByName(apiKey) ? "none" : "all";
1756
+ }
1757
+ function keyNames(apiKey, names, prefixes) {
1758
+ if (names.length + prefixes.length > 0)
1759
+ return [...names, ...prefixes.map((prefix) => `${prefix}*`)].join(", ");
1760
+ return apiKey.buckets.length || narrowedByName(apiKey) ? "none" : "all";
1620
1761
  }
1621
1762
  function keyDatabases(apiKey) {
1622
- if (narrowedByDatabase(apiKey))
1623
- return [...apiKey.databases, ...apiKey.database_prefixes.map((prefix) => `${prefix}*`)].join(
1624
- ", "
1625
- );
1626
- return apiKey.buckets.length ? "none" : "all";
1763
+ return keyNames(apiKey, apiKey.databases, apiKey.database_prefixes);
1764
+ }
1765
+ function keyStreams(apiKey) {
1766
+ return keyNames(apiKey, apiKey.streams, apiKey.stream_prefixes);
1627
1767
  }
1628
1768
  function apiKeyFields(apiKey) {
1629
1769
  return fields([
@@ -1634,6 +1774,7 @@ function apiKeyFields(apiKey) {
1634
1774
  ["Access", keyAccess(apiKey)],
1635
1775
  ["Buckets", keyBuckets(apiKey)],
1636
1776
  ["Databases", keyDatabases(apiKey)],
1777
+ ["Streams", keyStreams(apiKey)],
1637
1778
  ["Expires", when(apiKey.expires_at)],
1638
1779
  ["Last used", when(apiKey.last_used_at)],
1639
1780
  ["Created", apiKey.created_at]
@@ -1657,6 +1798,7 @@ async function apiKeyListCommand(deps, options = {}) {
1657
1798
  "ACCESS",
1658
1799
  "BUCKETS",
1659
1800
  "DATABASES",
1801
+ "STREAMS",
1660
1802
  "EXPIRES",
1661
1803
  "LAST USED"
1662
1804
  ],
@@ -1668,6 +1810,7 @@ async function apiKeyListCommand(deps, options = {}) {
1668
1810
  keyAccess(apiKey),
1669
1811
  keyBuckets(apiKey),
1670
1812
  keyDatabases(apiKey),
1813
+ keyStreams(apiKey),
1671
1814
  when(apiKey.expires_at),
1672
1815
  when(apiKey.last_used_at)
1673
1816
  ])
@@ -1684,6 +1827,8 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
1684
1827
  buckets: options.bucket,
1685
1828
  databases: options.database,
1686
1829
  database_prefixes: options.databasePrefix,
1830
+ streams: options.stream,
1831
+ stream_prefixes: options.streamPrefix,
1687
1832
  expires_in: options.expiresIn
1688
1833
  });
1689
1834
  print(
@@ -1811,11 +1956,11 @@ async function bucketDeleteCommand(deps, name, options = {}) {
1811
1956
  `Bucket ${bucket.name} is ${bucket.state}; its streams are being purged.`
1812
1957
  );
1813
1958
  }
1814
- function databaseAccess(database) {
1815
- return database.public_read ? "public" : "private";
1959
+ function readAccess(resource) {
1960
+ return resource.public_read ? "public" : "private";
1816
1961
  }
1817
- function databaseSize(database) {
1818
- const { log, snapshot } = database.stored_bytes;
1962
+ function storedSize(resource) {
1963
+ const { log, snapshot } = resource.stored_bytes;
1819
1964
  if (log === null && snapshot === null) return "unknown";
1820
1965
  return `${bytes(log)} log, ${bytes(snapshot, "no")} snapshot`;
1821
1966
  }
@@ -1828,11 +1973,12 @@ function databaseFields(database) {
1828
1973
  ["Kind", databaseOwner(database)],
1829
1974
  ["Region", database.region],
1830
1975
  ["State", database.state],
1831
- ["Read access", databaseAccess(database)],
1976
+ ["Read access", readAccess(database)],
1832
1977
  ["URL", database.url],
1978
+ ["Expires", database.expires_at ?? "never"],
1833
1979
  ["Incarnation", database.incarnation ?? "none yet"],
1834
1980
  ["Last write", when(database.last_write_at)],
1835
- ["Stored", databaseSize(database)],
1981
+ ["Stored", storedSize(database)],
1836
1982
  ["Created", `${database.created_at} by ${database.created_by}`]
1837
1983
  ]);
1838
1984
  }
@@ -1859,19 +2005,44 @@ async function databaseListCommand(deps, options = {}) {
1859
2005
  databaseOwner(database),
1860
2006
  database.region,
1861
2007
  database.state,
1862
- databaseAccess(database),
2008
+ readAccess(database),
1863
2009
  when(database.last_write_at),
1864
- databaseSize(database),
2010
+ storedSize(database),
1865
2011
  database.url
1866
2012
  ])
1867
2013
  ) : "No databases."
1868
2014
  );
1869
2015
  noticeUnavailable(deps, page.unavailable_regions, "sizes");
1870
2016
  }
2017
+ var EXPIRY_UNITS = { s: 1, m: 60, h: 3600, d: 86400 };
2018
+ var EXPIRES_IN_MIN_SECONDS = 90;
2019
+ function expiresInSeconds(value) {
2020
+ const match = /^([1-9][0-9]{0,8})([smhd])$/.exec(value.trim());
2021
+ if (!match)
2022
+ throw new Error(
2023
+ "Give a whole number of seconds, minutes, hours or days, such as 90s, 30m, 24h or 7d."
2024
+ );
2025
+ const seconds = Number(match[1]) * EXPIRY_UNITS[match[2]];
2026
+ if (seconds < EXPIRES_IN_MIN_SECONDS)
2027
+ throw new Error(
2028
+ `An expiry is at least ${EXPIRES_IN_MIN_SECONDS}s after this command creates it; give 90s or more.`
2029
+ );
2030
+ return seconds;
2031
+ }
2032
+ function creationExpiry(noun, options, now = Date.now()) {
2033
+ if (options.expiresIn !== void 0 && options.idempotencyKey !== void 0)
2034
+ throw new Error(
2035
+ `--expires-in counts from now, so a retry would name another expiry; retry an unconfirmed ${noun} creation with --idempotency-key and the --expires-at time its message printed.`
2036
+ );
2037
+ if (options.expiresAt !== void 0) return options.expiresAt;
2038
+ if (options.expiresIn === void 0) return void 0;
2039
+ return new Date(now + options.expiresIn * 1e3).toISOString();
2040
+ }
1871
2041
  async function databaseCreateCommand(deps, name, options = {}) {
2042
+ const expiresAt = creationExpiry("database", options);
1872
2043
  const database = await deps.api.createDatabase(
1873
2044
  await deps.auth.apiKey(),
1874
- { name, region: options.region },
2045
+ { name, region: options.region, expires_at: expiresAt },
1875
2046
  options.idempotencyKey ?? randomUUID2()
1876
2047
  );
1877
2048
  print(
@@ -1887,7 +2058,7 @@ async function databaseCreateCommand(deps, name, options = {}) {
1887
2058
  }
1888
2059
  function databaseNextStep(name) {
1889
2060
  return [
1890
- "Next: npm i @tonbo/sqlite-ursula",
2061
+ "Next: npm i --save-exact @tonbo/sqlite-ursula@0.11.2",
1891
2062
  ` open(${JSON.stringify(name)}) from "@tonbo/sqlite-ursula/tonbo"`
1892
2063
  ].join("\n");
1893
2064
  }
@@ -1926,22 +2097,17 @@ async function databaseDeleteCommand(deps, name, options = {}) {
1926
2097
  database.state === "deleted" ? `Deleted database ${database.name}; its name is free again.` : `Database ${database.name} is ${database.state}; its name is free again once it is deleted.`
1927
2098
  );
1928
2099
  }
1929
- function databaseUsageSort(value) {
2100
+ function usageSort(value) {
1930
2101
  if (value === "-list_value" || value === "list_value") return "-list_value";
1931
2102
  if (value === "name") return "name";
1932
2103
  throw new Error("--sort is list_value or name.");
1933
2104
  }
1934
- async function databaseUsageCommand(deps, options = {}) {
1935
- const usage = await deps.api.listDatabaseUsage(
1936
- await deps.auth.apiKey(),
1937
- { limit: options.limit, cursor: options.cursor },
1938
- { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
1939
- );
1940
- if (options.json) deps.output(JSON.stringify(usage));
2105
+ function printResourceUsage(deps, json, usage, noun) {
2106
+ if (json) deps.output(JSON.stringify(usage));
1941
2107
  else
1942
2108
  deps.output(
1943
2109
  [
1944
- `Database usage in ${usage.period} at list price, before the monthly allowance and credit (${usage.currency.toUpperCase()}; metered up to ${when(usage.as_of, "not yet metered")})`,
2110
+ `${noun === "database" ? "Database" : "Stream"} usage in ${usage.period} at list price, before the monthly allowance and credit (${usage.currency.toUpperCase()}; metered up to ${when(usage.as_of, "not yet metered")})`,
1945
2111
  usage.data.length ? table(
1946
2112
  [
1947
2113
  "NAME",
@@ -1961,7 +2127,7 @@ async function databaseUsageCommand(deps, options = {}) {
1961
2127
  row.snapshot_gib_months ?? "",
1962
2128
  usd(row.list_value_micros)
1963
2129
  ])
1964
- ) : "No database usage.",
2130
+ ) : `No ${noun} usage.`,
1965
2131
  `Other metered usage: ${usd(usage.other.list_value_micros)}`,
1966
2132
  "Charges, after the allowance and credit, are in `tonbo usage show`."
1967
2133
  ].join("\n")
@@ -1969,6 +2135,127 @@ async function databaseUsageCommand(deps, options = {}) {
1969
2135
  if (usage.next_cursor) deps.notice(`More results: run again with --cursor ${usage.next_cursor}`);
1970
2136
  noticeUnavailable(deps, usage.unavailable_regions, "usage");
1971
2137
  }
2138
+ async function databaseUsageCommand(deps, options = {}) {
2139
+ const usage = await deps.api.listDatabaseUsage(
2140
+ await deps.auth.apiKey(),
2141
+ { limit: options.limit, cursor: options.cursor },
2142
+ { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
2143
+ );
2144
+ printResourceUsage(deps, options.json, usage, "database");
2145
+ }
2146
+ function streamFields(stream) {
2147
+ return fields([
2148
+ ["Name", stream.name],
2149
+ ["Region", stream.region],
2150
+ ["State", stream.state],
2151
+ ["Content type", stream.content_type],
2152
+ ["Expires", stream.expires_at ?? "never"],
2153
+ ["Read access", readAccess(stream)],
2154
+ ["URL", stream.url],
2155
+ ["Incarnation", stream.incarnation ?? "none yet"],
2156
+ ["Last write", when(stream.last_write_at)],
2157
+ ["Stored", storedSize(stream)],
2158
+ ["Created", `${stream.created_at} by ${stream.created_by}`]
2159
+ ]);
2160
+ }
2161
+ async function streamListCommand(deps, options = {}) {
2162
+ const page = await deps.api.listStreams(
2163
+ await deps.auth.apiKey(),
2164
+ { limit: options.limit, cursor: options.cursor },
2165
+ options.prefix
2166
+ );
2167
+ printPage(
2168
+ deps,
2169
+ options.json,
2170
+ page,
2171
+ (streams) => streams.length ? table(
2172
+ ["NAME", "REGION", "STATE", "CONTENT TYPE", "READ ACCESS", "LAST WRITE", "STORED", "URL"],
2173
+ streams.map((stream) => [
2174
+ stream.name,
2175
+ stream.region,
2176
+ stream.state,
2177
+ stream.content_type,
2178
+ readAccess(stream),
2179
+ when(stream.last_write_at),
2180
+ storedSize(stream),
2181
+ stream.url
2182
+ ])
2183
+ ) : "No streams."
2184
+ );
2185
+ noticeUnavailable(deps, page.unavailable_regions, "sizes");
2186
+ }
2187
+ async function streamCreateCommand(deps, name, options = {}) {
2188
+ const expiresAt = creationExpiry("stream", options);
2189
+ const stream = await deps.api.createStream(
2190
+ await deps.auth.apiKey(),
2191
+ {
2192
+ name,
2193
+ region: options.region,
2194
+ content_type: options.contentType,
2195
+ expires_at: expiresAt
2196
+ },
2197
+ options.idempotencyKey ?? randomUUID2()
2198
+ );
2199
+ print(
2200
+ deps,
2201
+ options.json,
2202
+ stream,
2203
+ [
2204
+ stream.state === "active" ? `Created stream ${stream.name}.` : `Stream ${stream.name} is being created; \`tonbo stream show ${stream.name}\` reports it active when it is ready.`,
2205
+ streamFields(stream)
2206
+ ].join("\n")
2207
+ );
2208
+ if (!options.json)
2209
+ deps.notice(
2210
+ [
2211
+ `Next: append with POST ${stream.url} and Content-Type: ${stream.content_type}`,
2212
+ ` read from the start with GET ${stream.url}?offset=-1`
2213
+ ].join("\n")
2214
+ );
2215
+ }
2216
+ async function streamShowCommand(deps, name, options = {}) {
2217
+ const stream = await deps.api.getStream(await deps.auth.apiKey(), name);
2218
+ print(deps, options.json, stream, streamFields(stream));
2219
+ }
2220
+ async function streamUpdateCommand(deps, name, options = {}) {
2221
+ if (options.public === options.private)
2222
+ throw new Error("Pass exactly one of --public and --private.");
2223
+ const stream = await deps.api.updateStream(await deps.auth.apiKey(), name, {
2224
+ public_read: options.public === true
2225
+ });
2226
+ print(
2227
+ deps,
2228
+ options.json,
2229
+ stream,
2230
+ stream.public_read ? `Stream ${stream.name} is public: anyone can read it at ${stream.url}.` : `Stream ${stream.name} is private: only the account's keys can read it.`
2231
+ );
2232
+ }
2233
+ async function streamDeleteCommand(deps, name, options = {}) {
2234
+ await confirm(
2235
+ deps,
2236
+ options.yes,
2237
+ `delete stream ${name}`,
2238
+ () => Promise.resolve(
2239
+ `Delete stream ${name} and everything in it? This cannot be undone. Type the stream's name to confirm:`
2240
+ ),
2241
+ name
2242
+ );
2243
+ const stream = await deps.api.deleteStream(await deps.auth.apiKey(), name);
2244
+ print(
2245
+ deps,
2246
+ options.json,
2247
+ stream,
2248
+ stream.state === "deleted" ? `Deleted stream ${stream.name}; its name is free again.` : `Stream ${stream.name} is ${stream.state}; its name is free again once it is deleted.`
2249
+ );
2250
+ }
2251
+ async function streamUsageCommand(deps, options = {}) {
2252
+ const usage = await deps.api.listStreamUsage(
2253
+ await deps.auth.apiKey(),
2254
+ { limit: options.limit, cursor: options.cursor },
2255
+ { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
2256
+ );
2257
+ printResourceUsage(deps, options.json, usage, "stream");
2258
+ }
1972
2259
  async function usageShowCommand(deps, options = {}) {
1973
2260
  const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
1974
2261
  print(
@@ -2002,13 +2289,22 @@ var MACHINE_ERROR_HINTS = {
2002
2289
  hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
2003
2290
  account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
2004
2291
  image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
2292
+ image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
2005
2293
  shape_unavailable: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2006
2294
  port_reserved: "Serve the application on another port.",
2007
2295
  revision_mismatch: "The Machine changed since it was read; `tonbo machine show` it and run the update again.",
2008
2296
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
2009
2297
  database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
2010
2298
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
2011
- create_rate_limited: "Too many Machines were created in the last hour; try again later."
2299
+ create_rate_limited: "Too many Machines were created in the last hour; try again later.",
2300
+ 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.",
2301
+ 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`."
2302
+ };
2303
+ var MACHINE_IMAGE_FAILURE_HINTS = {
2304
+ image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
2305
+ image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`)",
2306
+ image_too_large: "Make the image smaller (fewer or smaller layers)",
2307
+ image_unsupported: "Rebuild the image without the entry the message names"
2012
2308
  };
2013
2309
  function explainMachineError(error) {
2014
2310
  if (!(error instanceof HttpError) || error.code === null) return error;
@@ -2038,6 +2334,29 @@ function idleActionText(machine) {
2038
2334
  function machineState(machine) {
2039
2335
  return machine.failure ? `${machine.state}: ${machine.failure.code}: ${machine.failure.message}` : machine.state;
2040
2336
  }
2337
+ function imageText(machine) {
2338
+ const { reference, repository, digest, state } = machine.image;
2339
+ if (digest === null) return `${reference} (platform image)`;
2340
+ const pulled = repository === null ? digest : `${repository}@${digest}`;
2341
+ const pulling = state === "resolved" && (machine.state === "creating" || machine.state === "active");
2342
+ return `${reference} (${pulled}, ${pulling ? "pulling" : state})`;
2343
+ }
2344
+ function imageNextStep(machine, hint) {
2345
+ if (machine.state === "deleting") return null;
2346
+ if (machine.state === "failed")
2347
+ return `${hint}. A failed Machine does not change: delete it with \`tonbo machine delete\` and create another that names the fixed image.`;
2348
+ return `${hint}, then name it again with \`tonbo machine update --image\`.`;
2349
+ }
2350
+ function imageFailureFields(machine) {
2351
+ const { failure } = machine.image;
2352
+ if (!failure) return [];
2353
+ const hint = MACHINE_IMAGE_FAILURE_HINTS[failure.code];
2354
+ const next = hint ? imageNextStep(machine, hint) : null;
2355
+ return [
2356
+ ["Image failure", `${failure.code}: ${failure.message}`],
2357
+ ...next ? [["Next step", next]] : []
2358
+ ];
2359
+ }
2041
2360
  function machineFields(machine) {
2042
2361
  return fields([
2043
2362
  ["Name", machine.name],
@@ -2046,7 +2365,8 @@ function machineFields(machine) {
2046
2365
  ["State", machineState(machine)],
2047
2366
  ["Power", machine.status.power],
2048
2367
  ["Shape", machine.shape],
2049
- ["Image", `${machine.image.reference} (${machine.image.digest})`],
2368
+ ["Image", imageText(machine)],
2369
+ ...imageFailureFields(machine),
2050
2370
  ["Command", machine.command ? machine.command.join(" ") : "the image's own"],
2051
2371
  ["Ports", machinePortsText(machine)],
2052
2372
  ["HTTPS", machine.hostnames.https],
@@ -2128,6 +2448,11 @@ async function machineListCommand(deps, options = {}) {
2128
2448
  );
2129
2449
  });
2130
2450
  }
2451
+ function createdText(machine) {
2452
+ if (machine.state === "creating")
2453
+ return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`;
2454
+ return `Created Machine ${machine.name}.`;
2455
+ }
2131
2456
  async function machineCreateCommand(deps, name, command, options = {}) {
2132
2457
  await machineCommand(async () => {
2133
2458
  if (!options.image) throw new Error("Pass --image <reference>.");
@@ -2151,15 +2476,13 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2151
2476
  },
2152
2477
  options.idempotencyKey ?? randomUUID3()
2153
2478
  );
2154
- print(
2155
- deps,
2156
- options.json,
2157
- machine,
2158
- [
2159
- machine.state === "active" ? `Created Machine ${machine.name}.` : `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`,
2160
- machineFields(machine)
2161
- ].join("\n")
2162
- );
2479
+ if (machine.state === "failed") {
2480
+ print(deps, options.json, machine, machineFields(machine));
2481
+ throw new Error(
2482
+ `Machine ${machine.name} failed to be created; delete it with \`tonbo machine delete ${machine.name}\` and create it again.`
2483
+ );
2484
+ }
2485
+ print(deps, options.json, machine, [createdText(machine), machineFields(machine)].join("\n"));
2163
2486
  });
2164
2487
  }
2165
2488
  async function machineShowCommand(deps, name, options = {}) {
@@ -2360,6 +2683,20 @@ function machineSpecOptions(command, update) {
2360
2683
  parseWhole
2361
2684
  );
2362
2685
  }
2686
+ function parseExpiresIn(value) {
2687
+ try {
2688
+ return expiresInSeconds(value);
2689
+ } catch (error) {
2690
+ throw new InvalidArgumentError(error.message);
2691
+ }
2692
+ }
2693
+ function parseUsageSort(value) {
2694
+ try {
2695
+ return usageSort(value);
2696
+ } catch (error) {
2697
+ throw new InvalidArgumentError(error.message);
2698
+ }
2699
+ }
2363
2700
  function createProgram(dependencies = createDependencies) {
2364
2701
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
2365
2702
  program.command("login").description("sign in through the browser and save a personal key for this computer").option("--account <name>", "the account to log in to; defaults to your personal account").action(async (options) => loginCommand(dependencies(), options));
@@ -2423,6 +2760,14 @@ function createProgram(dependencies = createDependencies) {
2423
2760
  "--database-prefix <prefix>",
2424
2761
  "narrow the key to the databases whose name starts with this, for its whole life; repeat for several; never with --bucket",
2425
2762
  repeated
2763
+ ).option(
2764
+ "--stream <name>",
2765
+ "narrow the key to this raw stream, for its whole life; repeat for several; never with --bucket",
2766
+ repeated
2767
+ ).option(
2768
+ "--stream-prefix <prefix>",
2769
+ "narrow the key to the raw streams whose name starts with this, for its whole life; repeat for several; never with --bucket",
2770
+ repeated
2426
2771
  ).addOption(
2427
2772
  new Option("--expires-in <period>", "when the key expires; never by default").choices(
2428
2773
  apiKeyExpirations
@@ -2483,16 +2828,26 @@ function createProgram(dependencies = createDependencies) {
2483
2828
  database.command("create <name>").description("create a database; its region is fixed for its life").option(
2484
2829
  "--region <region>",
2485
2830
  "the database's region (see tonbo region list); the database default region by default"
2831
+ ).addOption(
2832
+ new Option(
2833
+ "--expires-at <time>",
2834
+ "delete the database after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z; never by default"
2835
+ ).conflicts("expiresIn")
2836
+ ).addOption(
2837
+ new Option(
2838
+ "--expires-in <duration>",
2839
+ "delete the database this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never by default; never with --idempotency-key"
2840
+ ).argParser(parseExpiresIn).conflicts("expiresAt")
2486
2841
  ).option(
2487
2842
  "--idempotency-key <key>",
2488
- "reuse the key of an unconfirmed creation to get the database it created"
2843
+ "reuse the key of an unconfirmed creation to get the database it created; with --expires-at, never --expires-in"
2489
2844
  ),
2490
2845
  "createDatabase"
2491
2846
  ).action(
2492
2847
  async (name, options) => databaseCreateCommand(dependencies(), name, options)
2493
2848
  );
2494
2849
  operationOptions(
2495
- database.command("show <name>").description("show a database, its URL and size"),
2850
+ database.command("show <name>").description("show a database, its URL, expiry and size"),
2496
2851
  "getDatabase"
2497
2852
  ).action(
2498
2853
  async (name, options) => databaseShowCommand(dependencies(), name, options)
@@ -2513,21 +2868,70 @@ function createProgram(dependencies = createDependencies) {
2513
2868
  database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option(
2514
2869
  "--period <month>",
2515
2870
  "the calendar month in UTC, as YYYY-MM; the current one by default"
2516
- ).option(
2517
- "--sort <order>",
2518
- "list_value (highest first, the default) or name",
2519
- (value) => {
2520
- try {
2521
- return databaseUsageSort(value);
2522
- } catch (error) {
2523
- throw new InvalidArgumentError(error.message);
2524
- }
2525
- }
2526
- ).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
2871
+ ).option("--sort <order>", "list_value (highest first, the default) or name", parseUsageSort).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
2527
2872
  "listDatabaseUsage"
2528
2873
  ).action(
2529
2874
  async (options) => databaseUsageCommand(dependencies(), options)
2530
2875
  );
2876
+ const stream = program.command("stream").description("the account's raw durable streams");
2877
+ operationOptions(
2878
+ stream.command("list").description("list the streams in every region").option("--prefix <prefix>", "only streams whose name starts with this"),
2879
+ "listStreams"
2880
+ ).action(
2881
+ async (options) => streamListCommand(dependencies(), options)
2882
+ );
2883
+ operationOptions(
2884
+ stream.command("create <name>").description("create a stream; its region and content type are fixed for its life").option(
2885
+ "--region <region>",
2886
+ "the stream's region (see tonbo region list); the database default region by default"
2887
+ ).option(
2888
+ "--content-type <type>",
2889
+ "the media type every append sends, such as application/json; application/octet-stream by default"
2890
+ ).addOption(
2891
+ new Option(
2892
+ "--expires-at <time>",
2893
+ "delete the stream after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z; never by default"
2894
+ ).conflicts("expiresIn")
2895
+ ).addOption(
2896
+ new Option(
2897
+ "--expires-in <duration>",
2898
+ "delete the stream this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never by default; never with --idempotency-key"
2899
+ ).argParser(parseExpiresIn).conflicts("expiresAt")
2900
+ ).option(
2901
+ "--idempotency-key <key>",
2902
+ "reuse the key of an unconfirmed creation to get the stream it created; with --expires-at, never --expires-in"
2903
+ ),
2904
+ "createStream"
2905
+ ).action(
2906
+ async (name, options) => streamCreateCommand(dependencies(), name, options)
2907
+ );
2908
+ operationOptions(
2909
+ stream.command("show <name>").description("show a stream, its URL, content type, expiry and size"),
2910
+ "getStream"
2911
+ ).action(
2912
+ async (name, options) => streamShowCommand(dependencies(), name, options)
2913
+ );
2914
+ operationOptions(
2915
+ stream.command("update <name>").description("publish a stream for anonymous reads, or take it private (owners and admins)").addOption(new Option("--public", "serve anonymous reads").conflicts("private")).addOption(new Option("--private", "serve only the account's keys").conflicts("public")),
2916
+ "updateStream"
2917
+ ).action(
2918
+ async (name, options) => streamUpdateCommand(dependencies(), name, options)
2919
+ );
2920
+ operationOptions(
2921
+ stream.command("delete <name>").description("delete a stream and everything in it").option("--yes", "delete without asking; required when stdin is not a terminal"),
2922
+ "deleteStream"
2923
+ ).action(
2924
+ async (name, options) => streamDeleteCommand(dependencies(), name, options)
2925
+ );
2926
+ operationOptions(
2927
+ stream.command("usage").description("each stream's usage of one month at list price, before allowance and credit").option(
2928
+ "--period <month>",
2929
+ "the calendar month in UTC, as YYYY-MM; the current one by default"
2930
+ ).option("--sort <order>", "list_value (highest first, the default) or name", parseUsageSort).option("--prefix <prefix>", "only streams whose name starts with this").option("--names <names>", "only these streams, up to 100 names, comma-separated"),
2931
+ "listStreamUsage"
2932
+ ).action(
2933
+ async (options) => streamUsageCommand(dependencies(), options)
2934
+ );
2531
2935
  const machine = program.command("machine").description("the account's Machines");
2532
2936
  operationOptions(
2533
2937
  machine.command("list").description("list the Machines in every region"),
@@ -2538,8 +2942,11 @@ function createProgram(dependencies = createDependencies) {
2538
2942
  operationOptions(
2539
2943
  machineSpecOptions(
2540
2944
  machine.command("create <name> [command...]").description(
2541
- "create a Machine from a public linux/arm64 image; [command...] after -- replaces the image's CMD"
2542
- ).requiredOption("--image <reference>", "the public OCI image the Machine boots").option(
2945
+ "create a Machine from an image; [command...] after -- replaces the image's CMD"
2946
+ ).requiredOption(
2947
+ "--image <reference>",
2948
+ "the image the Machine boots: tonbo.dev/platform, or a public linux/arm64 OCI image once registry images are offered"
2949
+ ).option(
2543
2950
  "--region <region>",
2544
2951
  "the Machine's region, fixed for its life; the account's default region by default"
2545
2952
  ),
@@ -2562,7 +2969,7 @@ function createProgram(dependencies = createDependencies) {
2562
2969
  machineSpecOptions(
2563
2970
  machine.command("update <name> [command...]").description(
2564
2971
  "change a Machine; [command...] after -- replaces its command at the next boot"
2565
- ).option("--image <reference>", "a new image; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option(
2972
+ ).option("--image <reference>", "a new image, named as for create; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option(
2566
2973
  "--revision <revision>",
2567
2974
  "write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
2568
2975
  parseWhole
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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": {