@tonbo/cli 0.9.0 → 0.10.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.
package/README.md CHANGED
@@ -14,9 +14,9 @@ tonbo login [--account <name>]
14
14
  tonbo logout
15
15
  ```
16
16
 
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.
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. `tonbo` acts in the account of the last login: log in again to switch accounts. The file keeps one login key per account of the computer beside it, for `yield` ([`credentials-v1.json`](../../contracts/cli/credentials-v1.json) `accounts`); a key in a non-personal account is named `Yield CLI · <8 hex>` instead of after the host. 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
- 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`.
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, revoking every account's key of the file. The login is saved at `${XDG_CONFIG_HOME:-$HOME/.config}/tonbo/credentials.json` as `{version, account_id, key_id, key, expires_at}`, with `accounts` and `login_secret` beside them. 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
 
21
21
  `logout` revokes the saved key through the Management API and removes the file. If the revocation fails, the file is still removed and the CLI reports the key id, so you can revoke it under API keys in the console.
22
22
 
@@ -27,7 +27,7 @@ The login and the credentials file are shared with [`yield`](../yield) through `
27
27
 
28
28
  ## Commands and the Management API
29
29
 
30
- Every command other than `login` and `logout` is named by one operation of the [Management API](../../contracts/management/openapi-v1.yaml) through its `x-tonbo-cli` and calls that operation, plus reads of the same resource (a `--wait`, or the Machine a `name_taken` refusal names); `tonbo machine logs`, once it ships, is the named Streams exception, reading the stream at the Machine's `logs.url`. The command set and the operations tagged with a command are the same set, which `test/commands.test.ts` checks in both directions. The CLI resolves each request from the operation table of the internal package [`@tonbo/cli-core`](../../packages/tonbo-cli-core) (`src/generated/contracts.ts`, which `pnpm --filter @tonbo/cli-core generate:contracts` derives from the contract). `login` and `logout` are the named exception: they obtain and discard the key the other commands present as their Bearer credential. What a command may do is what your role in the logged-in account allows; the CLI never decides it.
30
+ Every command other than `login` and `logout` is named by one operation of the [Management API](../../contracts/management/openapi-v1.yaml) through its `x-tonbo-cli` and calls that operation, plus reads of the same resource (a `--wait`, the Machine a `name_taken` refusal names, or the activations `machine show` reads for the last end); `tonbo machine logs`, once it ships, is the named Streams exception, reading the stream at the Machine's `logs.url`. The command set and the operations tagged with a command are the same set, which `test/commands.test.ts` checks in both directions. The CLI resolves each request from the operation table of the internal package [`@tonbo/cli-core`](../../packages/tonbo-cli-core) (`src/generated/contracts.ts`, which `pnpm --filter @tonbo/cli-core generate:contracts` derives from the contract). `login` and `logout` are the named exception: they obtain and discard the key the other commands present as their Bearer credential. What a command may do is what your role in the logged-in account allows; the CLI never decides it.
31
31
 
32
32
  | Command | Operation | Request |
33
33
  | --- | --- | --- |
@@ -65,6 +65,7 @@ Every command other than `login` and `logout` is named by one operation of the [
65
65
  | `tonbo machine update <name> [--image <reference>] [--image-command] [--shape <shape>] [--port <port[:access]>]… [--clear-ports] [--default-port <port>] [--env <KEY=VALUE>]… [--unset-env <KEY>]… [--sleep-after <seconds>] [--idle-action <action>] [--stop-grace <seconds>] [--discard-snapshot] [--if-match <etag>] [-- <command>…]` | `updateMachine` | `PATCH /v1/machines/{name}` |
66
66
  | `tonbo machine sleep <name> [--if-match <etag>] [--wait [--timeout <seconds>]]` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
67
67
  | `tonbo machine delete <name> [--yes] [--if-match <etag>]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
68
+ | `tonbo machine activations <name> [--limit <n>] [--cursor <c>]` | `listMachineActivations` | `GET /v1/machines/{name}/activations` |
68
69
  | `tonbo machine shapes` | `listMachineShapes` | `GET /v1/machine-shapes` |
69
70
 
70
71
  ### Output and errors
@@ -166,10 +167,11 @@ tonbo machine create web --image tonbo.dev/platform --port 8080:public --env MOD
166
167
  tonbo machine create worker --image tonbo.dev/platform -- sh -c 'while true; do date; sleep 60; done'
167
168
  tonbo machine update web --env MODE=staging --sleep-after 300
168
169
  tonbo machine sleep web
170
+ tonbo machine activations web
169
171
  tonbo machine delete web
170
172
  ```
171
173
 
172
- 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, `machine create`, `update`, `sleep` and `delete` are refused with `region_not_offered`, and the CLI says that nothing was created or changed; `machine list`, `show` and `shapes` still answer. A region that cannot be reached is refused with `region_unavailable`, which a retry may clear.
174
+ 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, `machine create`, `update`, `sleep` and `delete` are refused with `region_not_offered`, and the CLI says that nothing was created or changed; `machine list`, `show` and `shapes` still answer. A region that cannot be reached is refused with `region_unavailable`, whose `Retry-After` says when a retry may clear it.
173
175
 
174
176
  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)`.
175
177
 
@@ -177,9 +179,11 @@ The image is `tonbo.dev/platform`, the platform image each node release ships, o
177
179
 
178
180
  `machine create` and `machine update` warn on stderr when the Machine ends up with no port (nothing can wake it until exec and SSH arrive) and when an `--env` key looks secret (`*_KEY`, `*_TOKEN`, `*SECRET*`, `*PASSWORD*`): env values are readable by every member through the API, and Machine secrets come later. When the default port is private, `machine create` and `machine show` print the `curl` call that reaches it with a personal key, and say that on a private port Tonbo authenticates the caller, so the app sees neither `Authorization` nor who called; an app with its own users serves a public port and does its own auth.
179
181
 
180
- `machine show` prints the state and what it means (an `active` Machine that is asleep boots on its first request, which the edge holds while it boots), the live activation and conditions when there are any, when the Machine sleeps and that its disk resets at every sleep until suspend ships, the database and logs URLs, a link to the durability text (the CLI prints the link only, never the text or a paraphrase of it), and the ETag. `machine show` prints image states as the API answers them: `pulling`, `ready`, `failed` or `not_pulled`.
182
+ `machine show` prints the state and what it means (an `active` Machine that is asleep boots on its first request, which the edge holds while it boots), the live activation and conditions when there are any, when the Machine sleeps and that its disk resets at every sleep until suspend ships, the database and logs URLs, a link to the durability text (the CLI prints the link only, never the text or a paraphrase of it), the ETag and who created the Machine. Each condition comes with what to do: a refused wake (`wake_blocked`) names its reason, when it was first refused and, for a crash backoff, when a retry may pass; `creation_delayed` says the creation is taking longer than usual and goes on. For an `active` Machine, `machine show` also reads its newest activations and prints how the last one ended (`Last end`), and when the live activation was admitted at an older revision than the Machine's it prints `Spec N, admitted at M` and says that ports and sleep-after already apply and that a changed image, command, shape or env applies after `tonbo machine sleep <name>`, since every spec change advances the revision. `machine show` prints image states as the API answers them: `pulling`, `ready`, `failed` or `not_pulled`. `machine list` prints each Machine's conditions in its last column.
181
183
 
182
- `machine update` 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, or another Machine that took the name, 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 unless `--default-port` names another. Ports and `--sleep-after` apply at once; image, command, shape and env apply at the next activation, and the command says how to apply them now: `machine sleep`, after which the next request boots the new spec; naming the same tag again re-resolves its digest. `machine sleep` puts an awake Machine to sleep now, and says so when it is already asleep or still creating; with `--wait` it reads the Machine until it is asleep. `machine delete` sends the ETag it read when it asked for confirmation, and `machine sleep` and `machine delete` send `--if-match`'s when given. `--if-match` takes the ETag `machine show` prints, with or without its double quotes: a value with neither a leading `"` nor `W/"` is quoted again, since a shell drops the quotes of a pasted tag. `machine shapes` prints each shape's list price for an hour at full use. `machine delete` deletes the Machine with its disk, database and logs, and its name is free for a new Machine at once.
184
+ `machine activations` lists a Machine's activations, newest first: the wake, what woke it, the spec revision and shape it ran, when it started and how it ended (its state, the reason such as `idle_early`, `workload_exited` with the exit code, or a refusal such as `payment_required`), or `live`.
185
+
186
+ `machine update` 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, or another Machine that took the name, 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 unless `--default-port` names another. Ports and `--sleep-after` apply at once; image, command, shape and env apply at the next activation, and the command says how to apply them now: `machine sleep`, after which the next request boots the new spec; naming the same tag again re-resolves its digest. `machine sleep` puts an awake Machine to sleep now, and says so when it is already asleep or still creating; with `--wait` it reads the Machine after the answer's `Retry-After`, and again every `sleep.poll_after_seconds`, until it is asleep or `--timeout` (300 s by default) passes. `machine delete` sends the ETag it read when it asked for confirmation, and `machine sleep` and `machine delete` send `--if-match`'s when given. `--if-match` takes the ETag `machine show` prints, with or without its double quotes: a value with neither a leading `"` nor `W/"` is quoted again, since a shell drops the quotes of a pasted tag. `machine shapes` prints each shape's list price for an hour at full use. `machine delete` deletes the Machine with its disk, database and logs, and its name is free for a new Machine at once.
183
187
 
184
188
  In a personal account, `machine list`, `machine show` and `machine delete` also cover your own computers, which `yield login` registers (Machines of kind `byoc`, which run nothing Tonbo operates). They are served even while Machines are not open in any region. `machine list` prints them in a second table with their system, agent harnesses, when they last reported and the state of their login key; `machine show` adds the hostname and device id, which only you see. `machine delete` forgets a computer: it disappears from the list at once and its name is free once the forget completes. Forgetting does not revoke the computer's key, so a computer whose key is still active registers again at its next start; the command says so and suggests `yield logout` on it or `tonbo api-key revoke` first. An organization lists managed Machines only.
185
189
 
package/dist/bin/tonbo.js CHANGED
@@ -202,6 +202,7 @@ var agentNameContract = {
202
202
  "artifacts",
203
203
  "auth",
204
204
  "inference",
205
+ "lb-check",
205
206
  "network-health",
206
207
  "sandbox-control",
207
208
  "status",
@@ -221,6 +222,7 @@ var agentNameContract = {
221
222
  "artifacts.tonbo.sh",
222
223
  "auth.tonbo.sh",
223
224
  "inference.tonbo.sh",
225
+ "lb-check.tonbo.sh",
224
226
  "network-health.tonbo.sh",
225
227
  "sandbox-control.tonbo.sh",
226
228
  "status.tonbo.sh",
@@ -621,6 +623,19 @@ var managementOperations = {
621
623
  "idempotencyKey": false,
622
624
  "cli": "tonbo machine sleep"
623
625
  },
626
+ "listMachineActivations": {
627
+ "method": "GET",
628
+ "path": "/machines/{name}/activations",
629
+ "pathParams": [
630
+ "name"
631
+ ],
632
+ "query": [
633
+ "cursor",
634
+ "limit"
635
+ ],
636
+ "idempotencyKey": false,
637
+ "cli": "tonbo machine activations"
638
+ },
624
639
  "listMachineShapes": {
625
640
  "method": "GET",
626
641
  "path": "/machine-shapes",
@@ -709,7 +724,7 @@ var apiKeyExpirations = [
709
724
  ];
710
725
  var credentialsContract = {
711
726
  "version": 1,
712
- "description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account. @tonbo/cli-core writes and reads it; other readers, such as @tonbo/sqlite-ursula/tonbo, follow `reader`.",
727
+ "description": "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.",
713
728
  "path": {
714
729
  "description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
715
730
  "base_env": "XDG_CONFIG_HOME",
@@ -747,6 +762,13 @@ var credentialsContract = {
747
762
  "expired": "A login whose `expires_at` is at or before now is expired: ask the person to run `tonbo login`. `expires_at: null` never expires.",
748
763
  "login_hint": "tonbo login"
749
764
  },
765
+ "accounts": {
766
+ "description": "One CLI login key per account (ADR-0016 R7 (a)): a daemon that publishes into an organization holds that organization's key beside the personal account's, since a key reaches only its own account. The fields are additive and optional, so a version 1 reader that knows only the top level keeps working, and the format stays version 1.",
767
+ "top_level": "The login made last, by `tonbo login` or `yield login`: the account `tonbo` acts in. A login made only to publish into an organization (`yield connect --account`) is added to `accounts` and leaves the top level alone.",
768
+ "entries": "`accounts` maps an account id to that account's login on this device, the top level's account included. `kind` and `name` are the redemption's answer; a file written before them, or by a server that does not answer them, has the top level alone, whose kind a client asks the API for.",
769
+ "personal": "The entry whose kind is `user` is the person's personal account's login: it alone captures raw sessions and registers the device (ADR-0016 W-10, W-14).",
770
+ "login_secret": "One random per-device secret, 32 bytes base64url, made at the first login and kept: it is sent only in the redeem POST, beside the PKCE verifier, never in the /cli/login URL, and User Center keeps only the suffix it derives from it, in an organization key's name `Yield CLI \xB7 <8 hex>`. `tonbo` and `yield` share it through this file, and so share one key per account. A lost file sends a new secret and gets a new suffix; the old key stays until it is revoked or idles out."
771
+ },
750
772
  "current_version": 1,
751
773
  "schema": {
752
774
  "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -787,6 +809,59 @@ var credentialsContract = {
787
809
  ],
788
810
  "format": "date-time",
789
811
  "description": "When the key expires, ISO 8601; null for a key that never expires."
812
+ },
813
+ "login_secret": {
814
+ "type": "string",
815
+ "pattern": "^[A-Za-z0-9_-]{43}$",
816
+ "description": "This device's login secret (`accounts.login_secret`)."
817
+ },
818
+ "accounts": {
819
+ "type": "object",
820
+ "description": "Every account's login of this device, keyed by the account id (`accounts.entries`).",
821
+ "additionalProperties": {
822
+ "type": "object",
823
+ "required": [
824
+ "key_id",
825
+ "key",
826
+ "expires_at"
827
+ ],
828
+ "properties": {
829
+ "key_id": {
830
+ "type": "string",
831
+ "minLength": 1,
832
+ "description": "The key's id."
833
+ },
834
+ "key": {
835
+ "type": "string",
836
+ "pattern": "^tbo_",
837
+ "description": "The personal key's secret."
838
+ },
839
+ "expires_at": {
840
+ "type": [
841
+ "string",
842
+ "null"
843
+ ],
844
+ "format": "date-time",
845
+ "description": "When the key expires; null for never."
846
+ },
847
+ "kind": {
848
+ "type": [
849
+ "string",
850
+ "null"
851
+ ],
852
+ "pattern": "^(user|organization)$",
853
+ "description": "The account's kind, `user` for the personal account; null when unknown."
854
+ },
855
+ "name": {
856
+ "type": [
857
+ "string",
858
+ "null"
859
+ ],
860
+ "minLength": 1,
861
+ "description": "The account's name, which `--account` takes; null when unknown."
862
+ }
863
+ }
864
+ }
790
865
  }
791
866
  }
792
867
  },
@@ -805,6 +880,30 @@ var credentialsContract = {
805
880
  "key_id": "20000000-0000-4000-8000-000000000002",
806
881
  "key": "tbo_secret",
807
882
  "expires_at": null
883
+ },
884
+ {
885
+ "version": 1,
886
+ "account_id": "10000000-0000-4000-8000-000000000001",
887
+ "key_id": "20000000-0000-4000-8000-000000000002",
888
+ "key": "tbo_secret",
889
+ "expires_at": null,
890
+ "login_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
891
+ "accounts": {
892
+ "10000000-0000-4000-8000-000000000001": {
893
+ "key_id": "20000000-0000-4000-8000-000000000002",
894
+ "key": "tbo_secret",
895
+ "expires_at": null,
896
+ "kind": "user",
897
+ "name": "sam"
898
+ },
899
+ "10000000-0000-4000-8000-0000000000a1": {
900
+ "key_id": "20000000-0000-4000-8000-0000000000a2",
901
+ "key": "tbo_org",
902
+ "expires_at": "2027-04-24T00:00:00.000Z",
903
+ "kind": "organization",
904
+ "name": "acme"
905
+ }
906
+ }
808
907
  }
809
908
  ],
810
909
  "unusable": [
@@ -846,6 +945,51 @@ var credentialsContract = {
846
945
  "account_id": "10000000-0000-4000-8000-000000000001",
847
946
  "key_id": "20000000-0000-4000-8000-000000000002",
848
947
  "key": "tbo_secret"
948
+ },
949
+ {
950
+ "version": 1,
951
+ "account_id": "10000000-0000-4000-8000-000000000001",
952
+ "key_id": "20000000-0000-4000-8000-000000000002",
953
+ "key": "tbo_secret",
954
+ "expires_at": null,
955
+ "accounts": {
956
+ "10000000-0000-4000-8000-0000000000a1": {
957
+ "key_id": "k",
958
+ "key": "not-a-personal-key",
959
+ "expires_at": null
960
+ }
961
+ }
962
+ },
963
+ {
964
+ "version": 1,
965
+ "account_id": "10000000-0000-4000-8000-000000000001",
966
+ "key_id": "20000000-0000-4000-8000-000000000002",
967
+ "key": "tbo_secret",
968
+ "expires_at": null,
969
+ "accounts": {
970
+ "10000000-0000-4000-8000-0000000000a1": {
971
+ "key_id": "k",
972
+ "key": "tbo_org",
973
+ "expires_at": null,
974
+ "kind": "team"
975
+ }
976
+ }
977
+ },
978
+ {
979
+ "version": 1,
980
+ "account_id": "10000000-0000-4000-8000-000000000001",
981
+ "key_id": "20000000-0000-4000-8000-000000000002",
982
+ "key": "tbo_secret",
983
+ "expires_at": null,
984
+ "accounts": []
985
+ },
986
+ {
987
+ "version": 1,
988
+ "account_id": "10000000-0000-4000-8000-000000000001",
989
+ "key_id": "20000000-0000-4000-8000-000000000002",
990
+ "key": "tbo_secret",
991
+ "expires_at": null,
992
+ "login_secret": "in-a-url"
849
993
  }
850
994
  ]
851
995
  }
@@ -860,6 +1004,7 @@ var machinePortAccess = [
860
1004
  ];
861
1005
  var machineTimings = {
862
1006
  "pollAfterSeconds": 2,
1007
+ "sleepPollAfterSeconds": 5,
863
1008
  "wakeHoldSeconds": 25,
864
1009
  "earlyReclaimSeconds": 120
865
1010
  };
@@ -929,13 +1074,37 @@ var FileCredentialStore = class {
929
1074
  await rm(this.filename, { force: true });
930
1075
  }
931
1076
  };
932
- var RULE_KEYWORDS = /* @__PURE__ */ new Set(["const", "type", "minLength", "pattern", "format", "description"]);
1077
+ var RULE_KEYWORDS = /* @__PURE__ */ new Set([
1078
+ "const",
1079
+ "type",
1080
+ "minLength",
1081
+ "pattern",
1082
+ "format",
1083
+ "description",
1084
+ "additionalProperties"
1085
+ ]);
1086
+ function isPlainObject(value) {
1087
+ return !!value && typeof value === "object" && !Array.isArray(value);
1088
+ }
1089
+ function matchesObject(value, rule) {
1090
+ if (rule.type !== "object" || !isPlainObject(value))
1091
+ return false;
1092
+ if (!rule.required.every((name) => Object.hasOwn(value, name)))
1093
+ return false;
1094
+ return Object.entries(rule.properties).every(([name, inner]) => !Object.hasOwn(value, name) || matchesRule(value[name], inner));
1095
+ }
933
1096
  function matchesRule(value, rule) {
934
1097
  if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
935
1098
  return false;
936
1099
  if ("const" in rule)
937
1100
  return value === rule.const;
938
1101
  const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
1102
+ if (types.includes("object")) {
1103
+ if (!isPlainObject(value) || !rule.additionalProperties)
1104
+ return false;
1105
+ const entries = rule.additionalProperties;
1106
+ return Object.entries(value).every(([key, entry]) => key.length > 0 && matchesObject(entry, entries));
1107
+ }
939
1108
  if (value === null)
940
1109
  return types.includes("null");
941
1110
  if (typeof value !== "string" || !types.includes("string"))
@@ -949,7 +1118,7 @@ function matchesRule(value, rule) {
949
1118
  return true;
950
1119
  }
951
1120
  function isStoredLogin(value) {
952
- if (!value || typeof value !== "object" || Array.isArray(value))
1121
+ if (!isPlainObject(value))
953
1122
  return false;
954
1123
  const login = value;
955
1124
  const schema = credentialsContract.schema;
@@ -960,6 +1129,44 @@ function isStoredLogin(value) {
960
1129
  const properties = schema.properties;
961
1130
  return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
962
1131
  }
1132
+ function accountLogins(login) {
1133
+ const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
1134
+ ...entry,
1135
+ account_id,
1136
+ kind: entry.kind ?? null,
1137
+ name: entry.name ?? null
1138
+ }));
1139
+ if (!entries.some((entry) => entry.account_id === login.account_id))
1140
+ entries.unshift({
1141
+ account_id: login.account_id,
1142
+ key_id: login.key_id,
1143
+ key: login.key,
1144
+ expires_at: login.expires_at,
1145
+ kind: null,
1146
+ name: null
1147
+ });
1148
+ return entries;
1149
+ }
1150
+ function withAccountLogin(file, entry, options) {
1151
+ const accounts = {};
1152
+ if (file)
1153
+ for (const known of accountLogins(file)) {
1154
+ const { account_id: account_id2, ...rest2 } = known;
1155
+ accounts[account_id2] = rest2;
1156
+ }
1157
+ const { account_id, ...rest } = entry;
1158
+ accounts[account_id] = rest;
1159
+ const top = options.current || !file ? entry : file;
1160
+ return {
1161
+ version: 1,
1162
+ account_id: top.account_id,
1163
+ key_id: top.key_id,
1164
+ key: top.key,
1165
+ expires_at: top.expires_at,
1166
+ login_secret: options.loginSecret,
1167
+ accounts
1168
+ };
1169
+ }
963
1170
  async function preparePrivateDirectory(directory) {
964
1171
  const stat = await lstat(directory);
965
1172
  if (stat.isSymbolicLink() || !stat.isDirectory())
@@ -989,6 +1196,9 @@ async function assertPrivateRegularFile(filename) {
989
1196
  var exec = promisify(execFile);
990
1197
  var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
991
1198
  var LOOPBACK_HOST = "127.0.0.1";
1199
+ function newLoginSecret() {
1200
+ return randomBytes(32).toString("base64url");
1201
+ }
992
1202
  function loginHostname(hostname = os2.hostname()) {
993
1203
  const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
994
1204
  return cleaned || "computer";
@@ -1023,11 +1233,21 @@ var AuthClient = class {
1023
1233
  }
1024
1234
  /**
1025
1235
  * The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
1026
- * a free port, then the one-time code is redeemed for a personal key named
1027
- * "CLI on <hostname>", which replaces that host's earlier login key.
1236
+ * a free port, then the one-time code is redeemed, with this device's login
1237
+ * secret, for the account's login key, which replaces this device's earlier
1238
+ * login key there ("CLI on <hostname>" in a personal account, `Yield CLI ·
1239
+ * <8 hex>` in any other). The key joins the file's `accounts`; it becomes
1240
+ * the top level too unless `current` is false (a login made only to publish
1241
+ * into an organization). Answers that account's login.
1028
1242
  */
1029
1243
  async login(progress = () => {
1030
- }, account) {
1244
+ }, account, { current = true } = {}) {
1245
+ const existing = await this.credentials.load().catch((error) => {
1246
+ if (error instanceof UnusableLoginError)
1247
+ return null;
1248
+ throw error;
1249
+ });
1250
+ const loginSecret = existing?.login_secret ?? newLoginSecret();
1031
1251
  const verifier = randomBytes(48).toString("base64url");
1032
1252
  const challenge = createHash("sha256").update(verifier).digest("base64url");
1033
1253
  const state = randomBytes(24).toString("base64url");
@@ -1047,18 +1267,24 @@ var AuthClient = class {
1047
1267
  const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
1048
1268
  method: "POST",
1049
1269
  headers: { "content-type": "application/json" },
1050
- body: JSON.stringify({ code, code_verifier: verifier })
1270
+ // The secret goes only here, in the POST body, never in the URL.
1271
+ body: JSON.stringify({ code, code_verifier: verifier, login_secret: loginSecret })
1051
1272
  });
1052
1273
  progress({ status: "completed", step: "key-redemption" });
1053
1274
  const login = {
1054
- version: 1,
1055
1275
  account_id: redeemed.account_id,
1056
1276
  key_id: redeemed.api_key.id,
1057
1277
  key: redeemed.key,
1058
- expires_at: redeemed.api_key.expires_at
1278
+ expires_at: redeemed.api_key.expires_at,
1279
+ kind: redeemed.account_kind ?? null,
1280
+ name: redeemed.account_name ?? null
1059
1281
  };
1060
1282
  progress({ status: "started", step: "credential-store" });
1061
- await this.credentials.save(login);
1283
+ const latest = await this.credentials.load().catch(() => existing);
1284
+ await this.credentials.save(withAccountLogin(latest, login, {
1285
+ current,
1286
+ loginSecret: latest?.login_secret ?? loginSecret
1287
+ }));
1062
1288
  progress({ status: "completed", step: "credential-store" });
1063
1289
  return login;
1064
1290
  }
@@ -1452,12 +1678,15 @@ var ManagementApi = class {
1452
1678
  });
1453
1679
  }
1454
1680
  /** With `etag`, only the Machine that ETag names, at that revision, is put
1455
- * to sleep (machine-v2.json `etag.if_match.sleep` is optional). */
1456
- sleepMachine(key, name, etag) {
1457
- return this.call("sleepMachine", key, {
1458
- params: { name },
1459
- ...etag ? { ifMatch: etag } : {}
1681
+ * to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
1682
+ * carries the `Retry-After` to read the Machine again after
1683
+ * (machine-v2.json `sleep.poll_after_seconds`); a 200 carries none. */
1684
+ async sleepMachine(key, name, etag) {
1685
+ const headers = { retryAfter: null };
1686
+ const answer = await this.request("sleepMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
1687
+ headers.retryAfter = response.headers.get("retry-after");
1460
1688
  });
1689
+ return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
1461
1690
  }
1462
1691
  /** The Machine being deleted or the device forgotten, with its kind; the
1463
1692
  * Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
@@ -1474,6 +1703,14 @@ var ManagementApi = class {
1474
1703
  return { machine: answer.data, kind: answer.data.kind };
1475
1704
  return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
1476
1705
  }
1706
+ /** One page of a managed Machine's activations, newest first; the cursor
1707
+ * pages that Machine only. */
1708
+ listMachineActivations(key, name, { limit, cursor } = {}) {
1709
+ return this.request("listMachineActivations", key, {
1710
+ params: { name },
1711
+ query: { limit, cursor }
1712
+ });
1713
+ }
1477
1714
  /** The whole answer: the shapes and the default one. */
1478
1715
  listMachineShapes(key) {
1479
1716
  return this.request("listMachineShapes", key, {});
@@ -1538,8 +1775,9 @@ function when(timestamp, absent = "never") {
1538
1775
  // ../../packages/tonbo-cli-core/dist/src/login.js
1539
1776
  async function loginCommand(deps, options = {}) {
1540
1777
  try {
1541
- const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account);
1542
- deps.output(`Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
1778
+ const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current });
1779
+ deps.output(`Logged in to account ${login.name ?? login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
1780
+ return login;
1543
1781
  } catch (error) {
1544
1782
  deps.progress.fail();
1545
1783
  throw error;
@@ -1603,17 +1841,23 @@ async function logoutCommand(deps) {
1603
1841
  deps.output("Not logged in.");
1604
1842
  return;
1605
1843
  }
1606
- let failure;
1607
- try {
1608
- await deps.api.revokeApiKey(login.key, login.key_id);
1609
- } catch (error) {
1610
- if (!(error instanceof HttpError && (error.status === 401 || error.status === 404)))
1611
- failure = error instanceof Error ? error : new Error("The revocation failed.");
1844
+ const failures = [];
1845
+ let first;
1846
+ for (const entry of accountLogins(login)) {
1847
+ try {
1848
+ await deps.api.revokeApiKey(entry.key, entry.key_id);
1849
+ } catch (error) {
1850
+ if (error instanceof HttpError && (error.status === 401 || error.status === 404))
1851
+ continue;
1852
+ const failure = error instanceof Error ? error : new Error("The revocation failed.");
1853
+ first ??= failure;
1854
+ failures.push(`${entry.key_id}: ${failure.message}`);
1855
+ }
1612
1856
  }
1613
1857
  await deps.credentials.remove();
1614
- if (failure)
1615
- throw new Error(`Removed the saved login, but could not revoke its key ${login.key_id}: ${failure.message} Revoke it under API keys in the Tonbo console.`, { cause: failure });
1616
- deps.output("Logged out. The CLI key is revoked.");
1858
+ if (failures.length)
1859
+ throw new Error(`Removed the saved login, but could not revoke ${failures.length === 1 ? "its key" : "these keys"} ${failures.join("; ")} Revoke ${failures.length === 1 ? "it" : "them"} under API keys in the Tonbo console.`, { cause: first });
1860
+ deps.output(login.accounts && Object.keys(login.accounts).length > 1 ? "Logged out. Every account's CLI key on this computer is revoked." : "Logged out. The CLI key is revoked.");
1617
1861
  }
1618
1862
 
1619
1863
  // ../../packages/tonbo-cli-core/dist/src/origin.js
@@ -2488,7 +2732,7 @@ function portText(port) {
2488
2732
  function machinePortsText(machine) {
2489
2733
  return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
2490
2734
  }
2491
- var { earlyReclaimSeconds, pollAfterSeconds, wakeHoldSeconds } = machineTimings;
2735
+ var { earlyReclaimSeconds, pollAfterSeconds, sleepPollAfterSeconds, wakeHoldSeconds } = machineTimings;
2492
2736
  function machineState(machine) {
2493
2737
  if (machine.failure)
2494
2738
  return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
@@ -2516,17 +2760,36 @@ function sleepText(machine) {
2516
2760
  function activationFields(machine) {
2517
2761
  const { activation } = machine;
2518
2762
  if (!activation) return [];
2519
- return [
2763
+ const fields2 = [
2520
2764
  [
2521
2765
  "Activation",
2522
2766
  `${activation.state} since ${activation.started_at} (wake ${activation.wake_gen}, from ${activation.source})`
2523
2767
  ]
2524
2768
  ];
2525
- }
2526
- function conditionText(condition) {
2769
+ if (typeof activation.revision === "number" && activation.revision !== machine.revision)
2770
+ fields2.push([
2771
+ "Pending",
2772
+ `Spec ${machine.revision}, admitted at ${activation.revision}: ports and sleep-after already apply; if the image, command, shape or env changed, run \`tonbo machine sleep ${machine.name}\` to apply it`
2773
+ ]);
2774
+ return fields2;
2775
+ }
2776
+ var MACHINE_WAKE_BLOCKED_HINTS = {
2777
+ payment_required: "The account's credit does not cover a wake: add credit, and the next request wakes it.",
2778
+ standing: "The account's Streams standing is not solvent: settle the account, and the next request wakes it.",
2779
+ meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
2780
+ crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
2781
+ capacity: "No host had room for its shape; the next request tries again.",
2782
+ region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
2783
+ };
2784
+ function conditionText(name, condition) {
2527
2785
  switch (condition.type) {
2528
- case "wake_blocked":
2529
- return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}`;
2786
+ case "wake_blocked": {
2787
+ const retry = condition.retry_at ? `, retry after ${condition.retry_at}` : "";
2788
+ const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name);
2789
+ return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}${retry}.${hint ? ` ${hint}` : ""}`;
2790
+ }
2791
+ case "creation_delayed":
2792
+ return `still creating since ${condition.since}, longer than usual; Tonbo's operators are alerted, and creation goes on until it is active or failed.`;
2530
2793
  case "node_unreachable":
2531
2794
  return `its host is unreachable since ${condition.since}`;
2532
2795
  case "egress_blocked":
@@ -2538,9 +2801,13 @@ function conditionText(condition) {
2538
2801
  function conditionFields(machine) {
2539
2802
  return (machine.status.conditions ?? []).map((condition) => [
2540
2803
  "Condition",
2541
- conditionText(condition)
2804
+ conditionText(machine.name, condition)
2542
2805
  ]);
2543
2806
  }
2807
+ function endText(end) {
2808
+ const status = end.exit_code !== null ? `, exit code ${end.exit_code}` : end.signal !== null ? `, signal ${end.signal}` : "";
2809
+ return `${end.state} (${end.reason.replaceAll("_", " ")}${status}) at ${end.at}`;
2810
+ }
2544
2811
  var DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees#sleep";
2545
2812
  var PRIVATE_PORTS_LINK = "https://tonbo.dev/docs/machines/requests#private-ports";
2546
2813
  function privatePortLines(machine) {
@@ -2574,7 +2841,7 @@ function imageFailureFields(machine) {
2574
2841
  ...next ? [["Next step", next]] : []
2575
2842
  ];
2576
2843
  }
2577
- function machineFields(machine, etag = null) {
2844
+ function machineFields(machine, etag = null, lastEnd = null) {
2578
2845
  return fields([
2579
2846
  ["Name", machine.name],
2580
2847
  ["ID", machine.id],
@@ -2582,6 +2849,7 @@ function machineFields(machine, etag = null) {
2582
2849
  ["State", machineState(machine)],
2583
2850
  ["Power", powerText(machine)],
2584
2851
  ...activationFields(machine),
2852
+ ...lastEnd ? [["Last end", endText(lastEnd)]] : [],
2585
2853
  ...conditionFields(machine),
2586
2854
  ["Sleeps", sleepText(machine)],
2587
2855
  ["Shape", machine.shape],
@@ -2600,12 +2868,13 @@ function machineFields(machine, etag = null) {
2600
2868
  ["Durability", DURABILITY_LINK],
2601
2869
  ["Revision", String(machine.revision)],
2602
2870
  ...etag ? [["ETag", etag]] : [],
2871
+ ...machine.created_by ? [["Created by", machine.created_by]] : [],
2603
2872
  ["Created", machine.created_at],
2604
2873
  ["Updated", machine.updated_at]
2605
2874
  ]);
2606
2875
  }
2607
- function machineText(machine, etag = null) {
2608
- return [machineFields(machine, etag), ...privatePortLines(machine)].join("\n");
2876
+ function machineText(machine, etag = null, lastEnd = null) {
2877
+ return [machineFields(machine, etag, lastEnd), ...privatePortLines(machine)].join("\n");
2609
2878
  }
2610
2879
  function keyStateText(machine) {
2611
2880
  const state = machine.byoc.key_state;
@@ -2630,8 +2899,8 @@ function deviceFields(machine) {
2630
2899
  ["Updated", machine.updated_at]
2631
2900
  ]);
2632
2901
  }
2633
- function anyMachineText(machine, etag = null) {
2634
- return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag);
2902
+ function anyMachineText(machine, etag = null, lastEnd = null) {
2903
+ return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag, lastEnd);
2635
2904
  }
2636
2905
  function parsePorts(specs) {
2637
2906
  const accesses = machinePortAccess;
@@ -2681,7 +2950,7 @@ function waitTimeout(options) {
2681
2950
  return options.timeout ?? DEFAULT_WAIT_SECONDS;
2682
2951
  }
2683
2952
  var DEFAULT_WAIT_SECONDS = 300;
2684
- async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
2953
+ async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
2685
2954
  const now = deps.now ?? Date.now;
2686
2955
  const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
2687
2956
  const start = now();
@@ -2697,7 +2966,7 @@ async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
2697
2966
  if (settled(machine))
2698
2967
  return { machine, last: machine, waited: Math.round((now() - start) / 1e3) };
2699
2968
  last = machine;
2700
- wait = pollAfterSeconds;
2969
+ wait = every;
2701
2970
  }
2702
2971
  }
2703
2972
  async function machineListCommand(deps, options = {}) {
@@ -2713,7 +2982,7 @@ async function machineListCommand(deps, options = {}) {
2713
2982
  if (managed.length)
2714
2983
  tables.push(
2715
2984
  table(
2716
- ["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
2985
+ ["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS", "CONDITIONS"],
2717
2986
  managed.map((machine) => [
2718
2987
  machine.name,
2719
2988
  machine.region,
@@ -2721,7 +2990,8 @@ async function machineListCommand(deps, options = {}) {
2721
2990
  machine.status.power,
2722
2991
  machine.shape,
2723
2992
  machine.image.reference,
2724
- machine.hostnames.https
2993
+ machine.hostnames.https,
2994
+ conditionsCell(machine)
2725
2995
  ])
2726
2996
  )
2727
2997
  );
@@ -2747,6 +3017,13 @@ async function machineListCommand(deps, options = {}) {
2747
3017
  );
2748
3018
  });
2749
3019
  }
3020
+ function conditionsCell(machine) {
3021
+ const conditions = machine.status.conditions ?? [];
3022
+ if (!conditions.length) return "-";
3023
+ return conditions.map(
3024
+ (condition) => condition.type === "wake_blocked" ? `wake_blocked:${condition.reason}` : condition.type
3025
+ ).join(",");
3026
+ }
2750
3027
  function createdText(machine) {
2751
3028
  if (machine.state === "creating")
2752
3029
  return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready, or create with --wait.`;
@@ -2783,6 +3060,7 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
2783
3060
  key,
2784
3061
  name,
2785
3062
  firstWait,
3063
+ pollAfterSeconds,
2786
3064
  timeout,
2787
3065
  (read) => read.state !== "creating"
2788
3066
  );
@@ -2870,8 +3148,46 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2870
3148
  }
2871
3149
  async function machineShowCommand(deps, name, options = {}) {
2872
3150
  await machineCommand(async () => {
2873
- const { machine, etag } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
2874
- print(deps, options.json, machine, anyMachineText(machine, etag));
3151
+ const key = await deps.auth.apiKey();
3152
+ const { machine, etag } = await deps.api.getMachineWithEtag(key, name);
3153
+ const lastEnd = options.json || machine.kind === "byoc" || machine.state !== "active" ? null : await lastActivationEnd(deps, key, machine.name);
3154
+ print(deps, options.json, machine, anyMachineText(machine, etag, lastEnd));
3155
+ });
3156
+ }
3157
+ async function lastActivationEnd(deps, key, name) {
3158
+ try {
3159
+ const page = await deps.api.listMachineActivations(key, name, { limit: 2 });
3160
+ return page.data.find((entry) => entry.end !== null)?.end ?? null;
3161
+ } catch (error) {
3162
+ if (!(error instanceof HttpError)) throw error;
3163
+ deps.notice(
3164
+ `Could not read how its last activation ended (${error.status}); \`tonbo machine activations ${name}\` lists them.`
3165
+ );
3166
+ return null;
3167
+ }
3168
+ }
3169
+ async function machineActivationsCommand(deps, name, options = {}) {
3170
+ await machineCommand(async () => {
3171
+ const page = await deps.api.listMachineActivations(await deps.auth.apiKey(), name, {
3172
+ limit: options.limit,
3173
+ cursor: options.cursor
3174
+ });
3175
+ printPage(
3176
+ deps,
3177
+ options.json,
3178
+ page,
3179
+ (activations) => activations.length ? table(
3180
+ ["WAKE", "SOURCE", "REVISION", "SHAPE", "STARTED", "END"],
3181
+ activations.map((entry) => [
3182
+ String(entry.wake_gen),
3183
+ entry.source,
3184
+ String(entry.revision),
3185
+ entry.shape,
3186
+ entry.started_at,
3187
+ entry.end ? endText(entry.end) : "live"
3188
+ ])
3189
+ ) : `Machine ${name} has not been woken yet.`
3190
+ );
2875
3191
  });
2876
3192
  }
2877
3193
  function appliesText(name, change) {
@@ -2954,7 +3270,11 @@ async function machineSleepCommand(deps, name, options = {}) {
2954
3270
  await machineCommand(async () => {
2955
3271
  const timeout = waitTimeout(options);
2956
3272
  const key = await deps.auth.apiKey();
2957
- const machine = await deps.api.sleepMachine(key, name, ifMatchHeader(options.ifMatch));
3273
+ const { machine, retryAfterSeconds: retryAfterSeconds2 } = await deps.api.sleepMachine(
3274
+ key,
3275
+ name,
3276
+ ifMatchHeader(options.ifMatch)
3277
+ );
2958
3278
  const going = options.wait && machine.state === "active" && machine.status.power !== "asleep";
2959
3279
  if (!going) {
2960
3280
  print(deps, options.json, machine, sleptText(machine));
@@ -2964,7 +3284,8 @@ async function machineSleepCommand(deps, name, options = {}) {
2964
3284
  deps,
2965
3285
  key,
2966
3286
  name,
2967
- pollAfterSeconds,
3287
+ retryAfterSeconds2 ?? sleepPollAfterSeconds,
3288
+ sleepPollAfterSeconds,
2968
3289
  timeout,
2969
3290
  (read) => read.status.power === "asleep" || read.state !== "active"
2970
3291
  );
@@ -3160,7 +3481,9 @@ function parseUsageSort(value) {
3160
3481
  }
3161
3482
  function createProgram(dependencies = createDependencies) {
3162
3483
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
3163
- 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));
3484
+ 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) => {
3485
+ await loginCommand(dependencies(), options);
3486
+ });
3164
3487
  program.command("logout").description("revoke this computer's key and remove the saved login").action(async () => logoutCommand(dependencies()));
3165
3488
  const account = program.command("account").description("the account you are logged in to");
3166
3489
  operationOptions(
@@ -3465,6 +3788,12 @@ function createProgram(dependencies = createDependencies) {
3465
3788
  ).action(
3466
3789
  async (name, options) => machineDeleteCommand(dependencies(), name, options)
3467
3790
  );
3791
+ operationOptions(
3792
+ machine.command("activations <name>").description("list a Machine's activations, newest first, and how each ended"),
3793
+ "listMachineActivations"
3794
+ ).action(
3795
+ async (name, options) => machineActivationsCommand(dependencies(), name, options)
3796
+ );
3468
3797
  operationOptions(
3469
3798
  machine.command("shapes").description("list the shapes a Machine may have"),
3470
3799
  "listMachineShapes"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.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": {