@tonbo/cli 0.6.1 → 0.7.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
@@ -50,22 +50,29 @@ Every command other than `login` and `logout` calls one operation of the [Manage
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
- | `tonbo database usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--limit <n>] [--cursor <c>]` | `listDatabaseUsage` | `GET /v1/usage/databases` |
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
54
  | `tonbo usage show [--period <YYYY-MM>]` | `getUsage` | `GET /v1/usage` |
55
+ | `tonbo machine list [--limit <n>] [--cursor <c>]` | `listMachines` | `GET /v1/machines` |
56
+ | `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` |
57
+ | `tonbo machine show <name>` | `getMachine` | `GET /v1/machines/{name}` |
58
+ | `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] [--revision <n>] [-- <command>…]` | `updateMachine` | `PATCH /v1/machines/{name}` |
59
+ | `tonbo machine sleep <name>` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
60
+ | `tonbo machine delete <name> [--yes]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
61
+ | `tonbo machine shapes` | `listMachineShapes` | `GET /v1/machine-shapes` |
55
62
 
56
63
  ### Output and errors
57
64
 
58
- 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` prints the whole answer, since its `other` row, checkpoint and prices belong with the rows. `--limit` asks for up to that many items (the API's default is 50, its maximum 200).
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).
59
66
 
60
67
  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`.
61
68
 
62
69
  ### Retried creations
63
70
 
64
- `agent create`, `bucket create` and `database 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.
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.
65
72
 
66
73
  ### Destructive commands
67
74
 
68
- `api-key revoke`, `bucket delete` and `database 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 or database's name, as in the console. Without a terminal they refuse unless `--yes` is given.
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.
69
76
 
70
77
  ## Account
71
78
 
@@ -124,6 +131,25 @@ Next: npm i @tonbo/sqlite-ursula
124
131
 
125
132
  `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.
126
133
 
134
+ ## Machines
135
+
136
+ ```console
137
+ 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
140
+ tonbo machine update web --env MODE=staging --sleep-after 300
141
+ tonbo machine sleep web
142
+ tonbo machine delete web
143
+ ```
144
+
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.
146
+
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.
148
+
149
+ `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
+
151
+ A refusal of machine-v2's `errors` prints the server's detail and then what to do next, for example `machine shapes` for `shape_unavailable` or a shorter name for `hostname_too_long`.
152
+
127
153
  ## Usage
128
154
 
129
155
  `usage show` prints the account's rated usage of one calendar month in UTC, the current one unless `--period YYYY-MM` names another: one line per product, region, dimension and model with its quantity and amount, and the total, in US dollars.
package/dist/bin/tonbo.js CHANGED
@@ -544,11 +544,79 @@ var managementOperations = {
544
544
  "period",
545
545
  "sort",
546
546
  "prefix",
547
+ "names",
547
548
  "cursor",
548
549
  "limit"
549
550
  ],
550
551
  "idempotencyKey": false,
551
552
  "cli": "tonbo database usage"
553
+ },
554
+ "listMachines": {
555
+ "method": "GET",
556
+ "path": "/machines",
557
+ "pathParams": [],
558
+ "query": [
559
+ "cursor",
560
+ "limit"
561
+ ],
562
+ "idempotencyKey": false,
563
+ "cli": "tonbo machine list"
564
+ },
565
+ "createMachine": {
566
+ "method": "POST",
567
+ "path": "/machines",
568
+ "pathParams": [],
569
+ "query": [],
570
+ "idempotencyKey": true,
571
+ "cli": "tonbo machine create"
572
+ },
573
+ "getMachine": {
574
+ "method": "GET",
575
+ "path": "/machines/{name}",
576
+ "pathParams": [
577
+ "name"
578
+ ],
579
+ "query": [],
580
+ "idempotencyKey": false,
581
+ "cli": "tonbo machine show"
582
+ },
583
+ "updateMachine": {
584
+ "method": "PATCH",
585
+ "path": "/machines/{name}",
586
+ "pathParams": [
587
+ "name"
588
+ ],
589
+ "query": [],
590
+ "idempotencyKey": false,
591
+ "cli": "tonbo machine update"
592
+ },
593
+ "deleteMachine": {
594
+ "method": "DELETE",
595
+ "path": "/machines/{name}",
596
+ "pathParams": [
597
+ "name"
598
+ ],
599
+ "query": [],
600
+ "idempotencyKey": false,
601
+ "cli": "tonbo machine delete"
602
+ },
603
+ "sleepMachine": {
604
+ "method": "POST",
605
+ "path": "/machines/{name}/sleep",
606
+ "pathParams": [
607
+ "name"
608
+ ],
609
+ "query": [],
610
+ "idempotencyKey": false,
611
+ "cli": "tonbo machine sleep"
612
+ },
613
+ "listMachineShapes": {
614
+ "method": "GET",
615
+ "path": "/machine-shapes",
616
+ "pathParams": [],
617
+ "query": [],
618
+ "idempotencyKey": false,
619
+ "cli": "tonbo machine shapes"
552
620
  }
553
621
  };
554
622
  var accountRoles = [
@@ -706,6 +774,14 @@ var credentialsContract = {
706
774
  ]
707
775
  }
708
776
  };
777
+ var machineIdleActions = [
778
+ "suspend",
779
+ "release"
780
+ ];
781
+ var machinePortAccess = [
782
+ "private",
783
+ "public"
784
+ ];
709
785
 
710
786
  // ../../packages/tonbo-cli-core/dist/src/credentials.js
711
787
  var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
@@ -1021,7 +1097,7 @@ var ManagementApi = class {
1021
1097
  page(operationId, key, { limit, cursor } = {}, query = {}) {
1022
1098
  return this.request(operationId, key, { query: { ...query, limit, cursor } });
1023
1099
  }
1024
- async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
1100
+ async request(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }) {
1025
1101
  const operation = managementOperations[operationId];
1026
1102
  const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
1027
1103
  const value = params[name];
@@ -1044,6 +1120,8 @@ var ManagementApi = class {
1044
1120
  headers["content-type"] = "application/json";
1045
1121
  if (idempotencyKey)
1046
1122
  headers["idempotency-key"] = idempotencyKey;
1123
+ if (ifMatch)
1124
+ headers["if-match"] = ifMatch;
1047
1125
  const queryString = search.toString();
1048
1126
  const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
1049
1127
  try {
@@ -1141,6 +1219,44 @@ var ManagementApi = class {
1141
1219
  getUsage(key, period) {
1142
1220
  return this.call("getUsage", key, { query: { period } });
1143
1221
  }
1222
+ listMachines(key, page) {
1223
+ return this.page("listMachines", key, page);
1224
+ }
1225
+ /** A replay with the same idempotency key returns the Machine the first
1226
+ * request created. */
1227
+ createMachine(key, request, idempotencyKey) {
1228
+ return this.call("createMachine", key, {
1229
+ body: withoutUndefined(request),
1230
+ idempotencyKey
1231
+ }).catch(unconfirmed("Machine creation", idempotencyKey, "the same arguments"));
1232
+ }
1233
+ getMachine(key, name) {
1234
+ return this.call("getMachine", key, { params: { name } });
1235
+ }
1236
+ /** Written only at `revision` (machine-v2 requires If-Match, the Machine's
1237
+ * ETag); a stale one is refused with `revision_mismatch`. */
1238
+ updateMachine(key, name, revision, change) {
1239
+ return this.call("updateMachine", key, {
1240
+ params: { name },
1241
+ body: withoutUndefined(change),
1242
+ ifMatch: `"${revision}"`
1243
+ });
1244
+ }
1245
+ sleepMachine(key, name) {
1246
+ return this.call("sleepMachine", key, { params: { name } });
1247
+ }
1248
+ /** The Machine being deleted, or null when the API answered 204: the
1249
+ * name's create never reached its region, and only the name was released. */
1250
+ async deleteMachine(key, name) {
1251
+ const answer = await this.request("deleteMachine", key, {
1252
+ params: { name }
1253
+ });
1254
+ return answer?.data ?? null;
1255
+ }
1256
+ /** The whole answer: the shapes and the default one. */
1257
+ listMachineShapes(key) {
1258
+ return this.request("listMachineShapes", key, {});
1259
+ }
1144
1260
  };
1145
1261
  function withoutUndefined(value) {
1146
1262
  return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
@@ -1819,7 +1935,7 @@ async function databaseUsageCommand(deps, options = {}) {
1819
1935
  const usage = await deps.api.listDatabaseUsage(
1820
1936
  await deps.auth.apiKey(),
1821
1937
  { limit: options.limit, cursor: options.cursor },
1822
- { period: options.period, sort: options.sort, prefix: options.prefix }
1938
+ { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
1823
1939
  );
1824
1940
  if (options.json) deps.output(JSON.stringify(usage));
1825
1941
  else
@@ -1877,6 +1993,283 @@ async function usageShowCommand(deps, options = {}) {
1877
1993
  );
1878
1994
  }
1879
1995
 
1996
+ // src/machine.ts
1997
+ import { randomUUID as randomUUID3 } from "node:crypto";
1998
+ var MACHINE_ERROR_HINTS = {
1999
+ region_unavailable: "Machines are not open there yet, or that region cannot be reached from here; nothing was created or changed.",
2000
+ name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
2001
+ hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
2002
+ hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
2003
+ account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
2004
+ image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
2005
+ shape_unavailable: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2006
+ port_reserved: "Serve the application on another port.",
2007
+ revision_mismatch: "The Machine changed since it was read; `tonbo machine show` it and run the update again.",
2008
+ snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
2009
+ database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
2010
+ 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."
2012
+ };
2013
+ function explainMachineError(error) {
2014
+ if (!(error instanceof HttpError) || error.code === null) return error;
2015
+ const hint = MACHINE_ERROR_HINTS[error.code];
2016
+ if (!hint) return error;
2017
+ return new HttpError(`${error.message}
2018
+ ${hint}`, error.status, error.body, error.requestId);
2019
+ }
2020
+ async function machineCommand(run) {
2021
+ try {
2022
+ return await run();
2023
+ } catch (error) {
2024
+ throw explainMachineError(error);
2025
+ }
2026
+ }
2027
+ function portText(port) {
2028
+ return `${port.port} ${port.access}${port.default ? " (default)" : ""}`;
2029
+ }
2030
+ function machinePortsText(machine) {
2031
+ return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
2032
+ }
2033
+ function idleActionText(machine) {
2034
+ const { action, reason } = machine.status.sleep;
2035
+ if (action === machine.idle_action) return action;
2036
+ return `${action} (${machine.idle_action} requested; ${reason.replaceAll("_", " ")})`;
2037
+ }
2038
+ function machineState(machine) {
2039
+ return machine.failure ? `${machine.state}: ${machine.failure.code}: ${machine.failure.message}` : machine.state;
2040
+ }
2041
+ function machineFields(machine) {
2042
+ return fields([
2043
+ ["Name", machine.name],
2044
+ ["ID", machine.id],
2045
+ ["Region", machine.region],
2046
+ ["State", machineState(machine)],
2047
+ ["Power", machine.status.power],
2048
+ ["Shape", machine.shape],
2049
+ ["Image", `${machine.image.reference} (${machine.image.digest})`],
2050
+ ["Command", machine.command ? machine.command.join(" ") : "the image's own"],
2051
+ ["Ports", machinePortsText(machine)],
2052
+ ["HTTPS", machine.hostnames.https],
2053
+ ["SSH", machine.hostnames.ssh],
2054
+ ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
2055
+ ["Sleep after", `${machine.sleep_after_seconds}s without a connection`],
2056
+ ["Idle action", idleActionText(machine)],
2057
+ ["Stop grace", `${machine.stop_grace_seconds}s`],
2058
+ ["Database", machine.database.name],
2059
+ ["Revision", String(machine.revision)],
2060
+ ["Created", machine.created_at],
2061
+ ["Updated", machine.updated_at]
2062
+ ]);
2063
+ }
2064
+ function parsePorts(specs, current) {
2065
+ const accesses = machinePortAccess;
2066
+ return specs.map((spec) => {
2067
+ const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
2068
+ const access = match?.[2] ?? "private";
2069
+ if (!match || !accesses.includes(access))
2070
+ throw new Error(
2071
+ `--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
2072
+ );
2073
+ const port = Number(match[1]);
2074
+ const before = current.find((entry) => entry.port === port && entry.access === access);
2075
+ return {
2076
+ port,
2077
+ access,
2078
+ default: false,
2079
+ ...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
2080
+ };
2081
+ });
2082
+ }
2083
+ function withDefault(ports, defaultPort) {
2084
+ if (ports.length === 0) {
2085
+ if (defaultPort !== void 0) throw new Error("--default-port needs a port to choose.");
2086
+ return ports;
2087
+ }
2088
+ const chosen = defaultPort ?? ports[0].port;
2089
+ if (!ports.some((entry) => entry.port === chosen))
2090
+ throw new Error(`--default-port ${chosen} is not one of the Machine's ports.`);
2091
+ return ports.map((entry) => ({ ...entry, default: entry.port === chosen }));
2092
+ }
2093
+ function parseEnv(pairs) {
2094
+ const env = {};
2095
+ for (const pair of pairs) {
2096
+ const split = pair.indexOf("=");
2097
+ if (split < 1) throw new Error(`--env ${pair}: give KEY=VALUE.`);
2098
+ env[pair.slice(0, split)] = pair.slice(split + 1);
2099
+ }
2100
+ return env;
2101
+ }
2102
+ async function machineListCommand(deps, options = {}) {
2103
+ await machineCommand(async () => {
2104
+ const page = await deps.api.listMachines(await deps.auth.apiKey(), {
2105
+ limit: options.limit,
2106
+ cursor: options.cursor
2107
+ });
2108
+ printPage(
2109
+ deps,
2110
+ options.json,
2111
+ page,
2112
+ (machines) => machines.length ? table(
2113
+ ["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
2114
+ machines.map((machine) => [
2115
+ machine.name,
2116
+ machine.region,
2117
+ machine.state,
2118
+ machine.status.power,
2119
+ machine.shape,
2120
+ machine.image.reference,
2121
+ machine.hostnames.https
2122
+ ])
2123
+ ) : "No Machines."
2124
+ );
2125
+ if (page.unavailable_regions.length)
2126
+ deps.notice(
2127
+ `Could not read the Machines in ${page.unavailable_regions.join(", ")}; they are missing from this list.`
2128
+ );
2129
+ });
2130
+ }
2131
+ async function machineCreateCommand(deps, name, command, options = {}) {
2132
+ await machineCommand(async () => {
2133
+ if (!options.image) throw new Error("Pass --image <reference>.");
2134
+ const env = parseEnv(options.env ?? []);
2135
+ const ports = withDefault(parsePorts(options.port ?? [], []), options.defaultPort);
2136
+ const key = await deps.auth.apiKey();
2137
+ const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
2138
+ const machine = await deps.api.createMachine(
2139
+ key,
2140
+ {
2141
+ name,
2142
+ image: options.image,
2143
+ shape,
2144
+ region: options.region,
2145
+ command: command.length ? command : void 0,
2146
+ ports: ports.length ? ports : void 0,
2147
+ env: Object.keys(env).length ? env : void 0,
2148
+ sleep_after_seconds: options.sleepAfter,
2149
+ idle_action: options.idleAction,
2150
+ stop_grace_seconds: options.stopGrace
2151
+ },
2152
+ options.idempotencyKey ?? randomUUID3()
2153
+ );
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
+ );
2163
+ });
2164
+ }
2165
+ async function machineShowCommand(deps, name, options = {}) {
2166
+ await machineCommand(async () => {
2167
+ const machine = await deps.api.getMachine(await deps.auth.apiKey(), name);
2168
+ print(deps, options.json, machine, machineFields(machine));
2169
+ });
2170
+ }
2171
+ async function machineUpdateCommand(deps, name, command, options = {}) {
2172
+ await machineCommand(async () => {
2173
+ if (command.length && options.imageCommand)
2174
+ throw new Error("Give a command or --image-command, not both.");
2175
+ if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
2176
+ throw new Error("Give --port or --clear-ports, not both.");
2177
+ const key = await deps.auth.apiKey();
2178
+ const current = await deps.api.getMachine(key, name);
2179
+ const change = {
2180
+ image: options.image,
2181
+ shape: options.shape,
2182
+ command: options.imageCommand ? null : command.length ? command : void 0,
2183
+ sleep_after_seconds: options.sleepAfter,
2184
+ idle_action: options.idleAction,
2185
+ stop_grace_seconds: options.stopGrace
2186
+ };
2187
+ if (options.clearPorts) change.ports = [];
2188
+ else if (options.port?.length) {
2189
+ const ports = parsePorts(options.port, current.ports);
2190
+ const kept = current.ports.find((entry) => entry.default)?.port;
2191
+ const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
2192
+ change.ports = withDefault(ports, defaultPort);
2193
+ } else if (options.defaultPort !== void 0)
2194
+ change.ports = withDefault(current.ports, options.defaultPort);
2195
+ if (options.env?.length || options.unsetEnv?.length) {
2196
+ const env = { ...current.env, ...parseEnv(options.env ?? []) };
2197
+ for (const variable of options.unsetEnv ?? []) delete env[variable];
2198
+ change.env = env;
2199
+ }
2200
+ if (options.discardSnapshot) change.discard_snapshot = true;
2201
+ if (Object.values(change).every((value) => value === void 0))
2202
+ throw new Error("Nothing to change: pass at least one option or a command.");
2203
+ const machine = await deps.api.updateMachine(
2204
+ key,
2205
+ name,
2206
+ options.revision ?? current.revision,
2207
+ change
2208
+ );
2209
+ print(
2210
+ deps,
2211
+ options.json,
2212
+ machine,
2213
+ [
2214
+ `Updated Machine ${machine.name} to revision ${machine.revision}. Ports and sleep-after apply now; image, command, shape and env at its next boot.`,
2215
+ machineFields(machine)
2216
+ ].join("\n")
2217
+ );
2218
+ });
2219
+ }
2220
+ async function machineSleepCommand(deps, name, options = {}) {
2221
+ await machineCommand(async () => {
2222
+ const machine = await deps.api.sleepMachine(await deps.auth.apiKey(), name);
2223
+ print(
2224
+ deps,
2225
+ options.json,
2226
+ machine,
2227
+ `Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); its next connection wakes it.`
2228
+ );
2229
+ });
2230
+ }
2231
+ async function machineDeleteCommand(deps, name, options = {}) {
2232
+ await machineCommand(async () => {
2233
+ await confirm(
2234
+ deps,
2235
+ options.yes,
2236
+ `delete Machine ${name}`,
2237
+ () => Promise.resolve(
2238
+ `Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`
2239
+ ),
2240
+ name
2241
+ );
2242
+ const machine = await deps.api.deleteMachine(await deps.auth.apiKey(), name);
2243
+ print(
2244
+ deps,
2245
+ options.json,
2246
+ machine,
2247
+ machine ? `Machine ${machine.name} is being deleted; its name is free for a new Machine.` : `Released the name ${name}; no Machine had been created under it.`
2248
+ );
2249
+ });
2250
+ }
2251
+ async function machineShapesCommand(deps, options = {}) {
2252
+ await machineCommand(async () => {
2253
+ const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
2254
+ print(
2255
+ deps,
2256
+ options.json,
2257
+ shapes,
2258
+ table(
2259
+ ["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
2260
+ shapes.data.map((shape) => [
2261
+ shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
2262
+ String(shape.vcpus),
2263
+ `${shape.memory_gib} GiB`,
2264
+ `${shape.scratch_gib} GiB`,
2265
+ shape.offered ? "yes" : "no",
2266
+ shape.suspendable ? "yes" : "no"
2267
+ ])
2268
+ )
2269
+ );
2270
+ });
2271
+ }
2272
+
1880
2273
  // src/prompt.ts
1881
2274
  import select from "@inquirer/select";
1882
2275
  import { createInterface } from "node:readline/promises";
@@ -1925,6 +2318,10 @@ function parseLimit(value) {
1925
2318
  if (!/^[1-9][0-9]*$/.test(value)) throw new InvalidArgumentError("Not a positive integer.");
1926
2319
  return Number(value);
1927
2320
  }
2321
+ function parseWhole(value) {
2322
+ if (!/^[0-9]+$/.test(value)) throw new InvalidArgumentError("Not a whole number.");
2323
+ return Number(value);
2324
+ }
1928
2325
  function operationOptions(command, operationId) {
1929
2326
  const query = managementOperations[operationId].query;
1930
2327
  if (query.includes("limit"))
@@ -1935,6 +2332,34 @@ function operationOptions(command, operationId) {
1935
2332
  }
1936
2333
  var repeated = (value, previous = []) => [...previous, value];
1937
2334
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
2335
+ function machineSpecOptions(command, update) {
2336
+ const later = update ? "; applies at the next boot" : "";
2337
+ return command.option("--shape <shape>", `the Machine's size (see tonbo machine shapes)${later}`).option(
2338
+ "--port <port[:access]>",
2339
+ `a port the Machine serves, private (the default) or public; repeat for several${update ? "; replaces every port" : ""}`,
2340
+ repeated
2341
+ ).option(
2342
+ "--default-port <port>",
2343
+ `the port served at the Machine's hostname without a port prefix; the first --port by default${update ? ", or the current default while --port still lists it" : ""}`,
2344
+ parseWhole
2345
+ ).option(
2346
+ "--env <KEY=VALUE>",
2347
+ `a non-secret environment variable; repeat for several${later}`,
2348
+ repeated
2349
+ ).option(
2350
+ "--sleep-after <seconds>",
2351
+ "how long the Machine stays awake without a connection",
2352
+ parseWhole
2353
+ ).addOption(
2354
+ new Option("--idle-action <action>", "what sleeping does to the Machine").choices(
2355
+ machineIdleActions
2356
+ )
2357
+ ).option(
2358
+ "--stop-grace <seconds>",
2359
+ "the seconds between SIGTERM and SIGKILL on a planned stop",
2360
+ parseWhole
2361
+ );
2362
+ }
1938
2363
  function createProgram(dependencies = createDependencies) {
1939
2364
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
1940
2365
  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));
@@ -2098,11 +2523,72 @@ function createProgram(dependencies = createDependencies) {
2098
2523
  throw new InvalidArgumentError(error.message);
2099
2524
  }
2100
2525
  }
2101
- ).option("--prefix <prefix>", "only databases whose name starts with this"),
2526
+ ).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
2102
2527
  "listDatabaseUsage"
2103
2528
  ).action(
2104
2529
  async (options) => databaseUsageCommand(dependencies(), options)
2105
2530
  );
2531
+ const machine = program.command("machine").description("the account's Machines");
2532
+ operationOptions(
2533
+ machine.command("list").description("list the Machines in every region"),
2534
+ "listMachines"
2535
+ ).action(
2536
+ async (options) => machineListCommand(dependencies(), options)
2537
+ );
2538
+ operationOptions(
2539
+ machineSpecOptions(
2540
+ 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(
2543
+ "--region <region>",
2544
+ "the Machine's region, fixed for its life; the account's default region by default"
2545
+ ),
2546
+ false
2547
+ ).option(
2548
+ "--idempotency-key <key>",
2549
+ "reuse the key of an unconfirmed creation to get the Machine it created"
2550
+ ),
2551
+ "createMachine"
2552
+ ).action(
2553
+ async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
2554
+ );
2555
+ operationOptions(
2556
+ machine.command("show <name>").description("show a Machine, its hostnames and its state"),
2557
+ "getMachine"
2558
+ ).action(
2559
+ async (name, options) => machineShowCommand(dependencies(), name, options)
2560
+ );
2561
+ operationOptions(
2562
+ machineSpecOptions(
2563
+ machine.command("update <name> [command...]").description(
2564
+ "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(
2566
+ "--revision <revision>",
2567
+ "write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
2568
+ parseWhole
2569
+ ).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
2570
+ true
2571
+ ),
2572
+ "updateMachine"
2573
+ ).action(
2574
+ async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
2575
+ );
2576
+ operationOptions(
2577
+ machine.command("sleep <name>").description("put a Machine to sleep now"),
2578
+ "sleepMachine"
2579
+ ).action(
2580
+ async (name, options) => machineSleepCommand(dependencies(), name, options)
2581
+ );
2582
+ operationOptions(
2583
+ machine.command("delete <name>").description("delete a Machine with its disk, database and logs").option("--yes", "delete without asking; required when stdin is not a terminal"),
2584
+ "deleteMachine"
2585
+ ).action(
2586
+ async (name, options) => machineDeleteCommand(dependencies(), name, options)
2587
+ );
2588
+ operationOptions(
2589
+ machine.command("shapes").description("list the shapes a Machine may have"),
2590
+ "listMachineShapes"
2591
+ ).action(async (options) => machineShapesCommand(dependencies(), options));
2106
2592
  operationOptions(
2107
2593
  program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
2108
2594
  "--period <month>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.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": {