@tonbo/cli 0.8.0 → 0.9.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
@@ -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` calls one operation of the [Management API](../../contracts/management/openapi-v1.yaml) and is named by that operation's `x-tonbo-cli`; 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`, 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.
31
31
 
32
32
  | Command | Operation | Request |
33
33
  | --- | --- | --- |
@@ -60,11 +60,11 @@ Every command other than `login` and `logout` calls one operation of the [Manage
60
60
  | `tonbo stream usage [--period <YYYY-MM>] [--sort list_value\|name] [--prefix <prefix>] [--names <a,b>] [--limit <n>] [--cursor <c>]` | `listStreamUsage` | `GET /v1/usage/streams` |
61
61
  | `tonbo usage show [--period <YYYY-MM>]` | `getUsage` | `GET /v1/usage` |
62
62
  | `tonbo machine list [--limit <n>] [--cursor <c>]` | `listMachines` | `GET /v1/machines` |
63
- | `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` |
63
+ | `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>] [--wait [--timeout <seconds>]] [-- <command>…]` | `createMachine` | `POST /v1/machines` |
64
64
  | `tonbo machine show <name>` | `getMachine` | `GET /v1/machines/{name}` |
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] [--revision <n>] [-- <command>…]` | `updateMachine` | `PATCH /v1/machines/{name}` |
66
- | `tonbo machine sleep <name>` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
67
- | `tonbo machine delete <name> [--yes]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
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
+ | `tonbo machine sleep <name> [--if-match <etag>] [--wait [--timeout <seconds>]]` | `sleepMachine` | `POST /v1/machines/{name}/sleep` |
67
+ | `tonbo machine delete <name> [--yes] [--if-match <etag>]` | `deleteMachine` | `DELETE /v1/machines/{name}` |
68
68
  | `tonbo machine shapes` | `listMachineShapes` | `GET /v1/machine-shapes` |
69
69
 
70
70
  ### Output and errors
@@ -169,17 +169,21 @@ tonbo machine sleep web
169
169
  tonbo machine delete web
170
170
  ```
171
171
 
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, every `tonbo machine` command is refused with `region_unavailable`, and the CLI says that nothing was created or changed.
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.
173
173
 
174
174
  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
175
 
176
- `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.
176
+ `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, which `machine show` marks as not served yet. Without `--shape` it takes the shape `machine shapes` marks as the default. `--port <port>` serves a private port (a member's token) and `--port <port>:public` an open one, which needs the `machines.public` capability and has no wake-rate, concurrency or runtime limit; 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`. With `--wait` the CLI reads the Machine at its `Location` after the answer's `Retry-After`, and again every `creation.poll_after_seconds`, until it is `active` or `failed` or `--timeout` (300 s by default) passes; a failure prints the Machine's failure with what to do and exits non-zero, and a timeout says it is still creating and undoes nothing. A create the network or a 5xx answer did not confirm is retried in the command with the same idempotency key, at most three times, honouring `Retry-After`; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply.
177
177
 
178
- `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.
178
+ `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
+
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`.
181
+
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.
179
183
 
180
184
  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.
181
185
 
182
- 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`.
186
+ A refusal of machine-v2's `errors`, or of a shared code such as `region_not_offered`, `precondition_required` or `precondition_failed`, prints the server's detail and then what to do next, for example `machine shapes` for `shape_not_offered` or a shorter name for `hostname_too_long`.
183
187
 
184
188
  ## Usage
185
189
 
package/dist/bin/tonbo.js CHANGED
@@ -858,6 +858,11 @@ var machinePortAccess = [
858
858
  "private",
859
859
  "public"
860
860
  ];
861
+ var machineTimings = {
862
+ "pollAfterSeconds": 2,
863
+ "wakeHoldSeconds": 25,
864
+ "earlyReclaimSeconds": 120
865
+ };
861
866
 
862
867
  // ../../packages/tonbo-cli-core/dist/src/credentials.js
863
868
  var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
@@ -1185,10 +1190,12 @@ var ManagementApi = class {
1185
1190
  fetcher;
1186
1191
  apiOrigin;
1187
1192
  loginHint;
1188
- constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT) {
1193
+ pause;
1194
+ constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
1189
1195
  this.fetcher = fetcher;
1190
1196
  this.apiOrigin = apiOrigin2;
1191
1197
  this.loginHint = loginHint;
1198
+ this.pause = pause;
1192
1199
  }
1193
1200
  /** The answer's `data`. A refusal throws an HttpError naming the request id. */
1194
1201
  async call(operationId, key, request = {}) {
@@ -1356,36 +1363,111 @@ var ManagementApi = class {
1356
1363
  listMachines(key, page) {
1357
1364
  return this.page("listMachines", key, page);
1358
1365
  }
1359
- /** A replay with the same idempotency key returns the Machine the first
1360
- * request created. */
1361
- createMachine(key, request, idempotencyKey) {
1362
- return this.call("createMachine", key, {
1363
- body: withoutUndefined(request),
1364
- idempotencyKey
1365
- }).catch(unconfirmed("Machine creation", idempotencyKey, "the same arguments"));
1366
+ /**
1367
+ * A replay with the same idempotency key returns the Machine the first
1368
+ * request created, so a transport failure or a 5xx answer is retried here
1369
+ * with the same key, at most `CREATE_ATTEMPTS` times, after the answer's
1370
+ * Retry-After (bounded) or a short backoff. A refusal is final; a create
1371
+ * still unconfirmed after the last attempt names the key to retry with.
1372
+ * The answer carries the Machine's `Location` and, while it is creating,
1373
+ * the `Retry-After` to read it again after (machine-v2.json
1374
+ * `creation.create_status`).
1375
+ */
1376
+ async createMachine(key, request, idempotencyKey) {
1377
+ let last;
1378
+ for (let attempt = 1; attempt <= CREATE_ATTEMPTS; attempt++) {
1379
+ const headers = {
1380
+ location: null,
1381
+ retryAfter: null
1382
+ };
1383
+ try {
1384
+ const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
1385
+ headers.location = response.headers.get("location");
1386
+ headers.retryAfter = response.headers.get("retry-after");
1387
+ });
1388
+ return {
1389
+ machine: answer.data,
1390
+ location: headers.location,
1391
+ retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
1392
+ };
1393
+ } catch (error) {
1394
+ if (error instanceof HttpError && error.status < 500)
1395
+ throw error;
1396
+ last = error;
1397
+ if (attempt === CREATE_ATTEMPTS)
1398
+ break;
1399
+ const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
1400
+ await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
1401
+ }
1402
+ }
1403
+ return unconfirmed("Machine creation", idempotencyKey, "the same arguments")(last);
1404
+ }
1405
+ /** The name of the Machine a create's `Location` names, when it is this
1406
+ * client's origin and `getMachine`'s path; null otherwise, so the key is
1407
+ * never sent to another origin and the caller reads the Machine by the
1408
+ * name it created. */
1409
+ machineNameAt(location) {
1410
+ if (!location)
1411
+ return null;
1412
+ let url;
1413
+ try {
1414
+ url = new URL(location, this.apiOrigin);
1415
+ } catch {
1416
+ return null;
1417
+ }
1418
+ if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
1419
+ return null;
1420
+ const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
1421
+ if (!url.pathname.startsWith(prefix))
1422
+ return null;
1423
+ const segment = url.pathname.slice(prefix.length);
1424
+ if (!segment || segment.includes("/"))
1425
+ return null;
1426
+ try {
1427
+ return decodeURIComponent(segment);
1428
+ } catch {
1429
+ return null;
1430
+ }
1366
1431
  }
1367
1432
  getMachine(key, name) {
1368
1433
  return this.call("getMachine", key, { params: { name } });
1369
1434
  }
1370
- /** Written only at `revision` (machine-v2 requires If-Match, the Machine's
1371
- * ETag); a stale one is refused with `revision_mismatch`. */
1372
- updateMachine(key, name, revision, change) {
1435
+ /** The Machine with its ETag, which is opaque (machine-v2.json `etag`)
1436
+ * and is sent back as it was read. */
1437
+ async getMachineWithEtag(key, name) {
1438
+ const headers = { etag: null };
1439
+ const answer = await this.request("getMachine", key, { params: { name } }, (response) => {
1440
+ headers.etag = response.headers.get("etag");
1441
+ });
1442
+ return { machine: answer.data, etag: headers.etag };
1443
+ }
1444
+ /** Written only to the Machine and revision `etag` names, the ETag as read
1445
+ * (machine-v2 requires If-Match on update); a stale one is refused with
1446
+ * `precondition_failed`. */
1447
+ updateMachine(key, name, etag, change) {
1373
1448
  return this.call("updateMachine", key, {
1374
1449
  params: { name },
1375
1450
  body: withoutUndefined(change),
1376
- ifMatch: `"${revision}"`
1451
+ ifMatch: etag
1377
1452
  });
1378
1453
  }
1379
- sleepMachine(key, name) {
1380
- return this.call("sleepMachine", key, { params: { name } });
1454
+ /** 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 } : {}
1460
+ });
1381
1461
  }
1382
1462
  /** The Machine being deleted or the device forgotten, with its kind; the
1383
1463
  * Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
1384
1464
  * says whether a name was released (`managed`: its create never reached its
1385
- * region) or a device was forgotten (`byoc`: its region held no row). */
1386
- async deleteMachine(key, name) {
1465
+ * region) or a device was forgotten (`byoc`: its region held no row). With
1466
+ * `etag`, only the Machine that ETag names is deleted
1467
+ * (machine-v2.json `etag.if_match.delete` is optional). */
1468
+ async deleteMachine(key, name, etag) {
1387
1469
  const headers = { kind: null };
1388
- const answer = await this.request("deleteMachine", key, { params: { name } }, (response) => {
1470
+ const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
1389
1471
  headers.kind = response.headers.get("x-tonbo-machine-kind");
1390
1472
  });
1391
1473
  if (answer?.data)
@@ -1397,6 +1479,13 @@ var ManagementApi = class {
1397
1479
  return this.request("listMachineShapes", key, {});
1398
1480
  }
1399
1481
  };
1482
+ var CREATE_ATTEMPTS = 3;
1483
+ var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
1484
+ function retryAfterSeconds(value) {
1485
+ if (value === null || !/^[0-9]+$/.test(value.trim()))
1486
+ return null;
1487
+ return Number(value.trim());
1488
+ }
1400
1489
  function withoutUndefined(value) {
1401
1490
  return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
1402
1491
  }
@@ -2330,16 +2419,19 @@ async function usageShowCommand(deps, options = {}) {
2330
2419
  // src/machine.ts
2331
2420
  import { randomUUID as randomUUID3 } from "node:crypto";
2332
2421
  var MACHINE_ERROR_HINTS = {
2333
- region_unavailable: "Machines are not open there yet, or that region cannot be reached from here; nothing was created or changed.",
2422
+ region_not_offered: "Machines are not offered in that region yet; nothing was created or changed. Name a region where they are offered.",
2423
+ region_unavailable: "That region cannot be reached right now; nothing was created or changed. Try again shortly.",
2334
2424
  name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
2335
2425
  hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
2336
2426
  hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
2337
2427
  account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
2338
2428
  image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
2339
2429
  image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
2340
- shape_unavailable: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2430
+ shape_not_offered: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2341
2431
  port_reserved: "Serve the application on another port.",
2342
- revision_mismatch: "The Machine changed since it was read; `tonbo machine show` it and run the update again.",
2432
+ precondition_required: "The API needs the Machine's ETag for this change; `tonbo machine show` prints it, and the command sends the ETag it reads unless --if-match names one.",
2433
+ precondition_failed: "The Machine changed since it was read, or another Machine now has its name; `tonbo machine show` it and run the command again.",
2434
+ machine_failed: "A Machine whose creation failed does not change: `tonbo machine show` says why; delete it with `tonbo machine delete` and create it again.",
2343
2435
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
2344
2436
  database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
2345
2437
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
@@ -2354,6 +2446,28 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
2354
2446
  image_too_large: "Make the image smaller (fewer or smaller layers)",
2355
2447
  image_unsupported: "Rebuild the image without the entry the message names"
2356
2448
  };
2449
+ var MACHINE_CREATION_FAILURE_HINTS = {
2450
+ database_creation_failed: "The Machine's database could not be created; the message names the stream and the code. If the code is payment_required, add credit to the account first; then delete the Machine and create it again.",
2451
+ database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`), then delete this Machine and create it again.",
2452
+ payment_required: "The account's credit is exhausted: add credit to the account, then delete the Machine and create it again.",
2453
+ ...Object.fromEntries(
2454
+ Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
2455
+ code,
2456
+ `${hint}, then delete the Machine and create it again naming the fixed image.`
2457
+ ])
2458
+ )
2459
+ };
2460
+ function creationFailureText(machine) {
2461
+ const { failure } = machine;
2462
+ if (!failure) return { reason: "", hint: "" };
2463
+ const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code];
2464
+ return { reason: ` (${failure.code}: ${failure.message})`, hint: hint ? `
2465
+ ${hint}` : "" };
2466
+ }
2467
+ function ifMatchHeader(value) {
2468
+ if (value === void 0) return void 0;
2469
+ return value.startsWith('"') || value.startsWith('W/"') ? value : `"${value}"`;
2470
+ }
2357
2471
  function explainMachineError(error) {
2358
2472
  if (!(error instanceof HttpError) || error.code === null) return error;
2359
2473
  const hint = MACHINE_ERROR_HINTS[error.code];
@@ -2374,20 +2488,75 @@ function portText(port) {
2374
2488
  function machinePortsText(machine) {
2375
2489
  return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
2376
2490
  }
2377
- function idleActionText(machine) {
2491
+ var { earlyReclaimSeconds, pollAfterSeconds, wakeHoldSeconds } = machineTimings;
2492
+ function machineState(machine) {
2493
+ if (machine.failure)
2494
+ return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
2495
+ if (machine.state === "creating")
2496
+ return `creating: not ready for requests yet; read it again in ${pollAfterSeconds} s, or create with --wait`;
2497
+ return machine.state;
2498
+ }
2499
+ function powerText(machine) {
2500
+ const { power } = machine.status;
2501
+ if (machine.state !== "active") return power;
2502
+ if (power === "asleep")
2503
+ return `asleep: the first request boots it, and the edge holds that request for up to ${wakeHoldSeconds} s`;
2504
+ if (power === "awake") return "awake: serving requests";
2505
+ return power;
2506
+ }
2507
+ function duration(seconds) {
2508
+ return seconds % 60 === 0 && seconds >= 120 ? `${seconds / 60} min` : `${seconds} s`;
2509
+ }
2510
+ function sleepText(machine) {
2378
2511
  const { action, reason } = machine.status.sleep;
2379
- if (action === machine.idle_action) return action;
2380
- return `${action} (${machine.idle_action} requested; ${reason.replaceAll("_", " ")})`;
2512
+ const when2 = `after about ${duration(earlyReclaimSeconds)} idle (early) or at most ${machine.sleep_after_seconds} s without a connection`;
2513
+ if (action !== "release") return `${when2}; it then ${action}s`;
2514
+ return `${when2}; the disk resets at every sleep${reason === "suspend_unavailable" ? " until suspend ships" : ""}`;
2381
2515
  }
2382
- function machineState(machine) {
2383
- return machine.failure ? `${machine.state}: ${machine.failure.code}: ${machine.failure.message}` : machine.state;
2516
+ function activationFields(machine) {
2517
+ const { activation } = machine;
2518
+ if (!activation) return [];
2519
+ return [
2520
+ [
2521
+ "Activation",
2522
+ `${activation.state} since ${activation.started_at} (wake ${activation.wake_gen}, from ${activation.source})`
2523
+ ]
2524
+ ];
2525
+ }
2526
+ function conditionText(condition) {
2527
+ switch (condition.type) {
2528
+ case "wake_blocked":
2529
+ return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}`;
2530
+ case "node_unreachable":
2531
+ return `its host is unreachable since ${condition.since}`;
2532
+ case "egress_blocked":
2533
+ return `outbound access blocked for the ${condition.scope} since ${condition.since}${condition.until ? ` until ${condition.until}` : ""}`;
2534
+ default:
2535
+ return JSON.stringify(condition);
2536
+ }
2537
+ }
2538
+ function conditionFields(machine) {
2539
+ return (machine.status.conditions ?? []).map((condition) => [
2540
+ "Condition",
2541
+ conditionText(condition)
2542
+ ]);
2543
+ }
2544
+ var DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees#sleep";
2545
+ var PRIVATE_PORTS_LINK = "https://tonbo.dev/docs/machines/requests#private-ports";
2546
+ function privatePortLines(machine) {
2547
+ const port = machine.ports.find((entry) => entry.default);
2548
+ if (!port || port.access !== "private") return [];
2549
+ return [
2550
+ `Port ${port.port} is private: call it with a personal key from \`tonbo api-key create\` (see ${PRIVATE_PORTS_LINK}):`,
2551
+ ` curl -H "Authorization: Bearer $TONBO_KEY" ${machine.hostnames.https}/`,
2552
+ "On a private port Tonbo authenticates the caller, and the app sees neither Authorization nor who called; an app with its own users serves a public port and does its own auth."
2553
+ ];
2384
2554
  }
2385
2555
  function imageText(machine) {
2386
2556
  const { reference, repository, digest, state } = machine.image;
2387
2557
  if (digest === null) return `${reference} (platform image)`;
2388
2558
  const pulled = repository === null ? digest : `${repository}@${digest}`;
2389
- const pulling = state === "resolved" && (machine.state === "creating" || machine.state === "active");
2390
- return `${reference} (${pulled}, ${pulling ? "pulling" : state})`;
2559
+ return `${reference} (${pulled}, ${state})`;
2391
2560
  }
2392
2561
  function imageNextStep(machine, hint) {
2393
2562
  if (machine.state === "deleting") return null;
@@ -2405,30 +2574,39 @@ function imageFailureFields(machine) {
2405
2574
  ...next ? [["Next step", next]] : []
2406
2575
  ];
2407
2576
  }
2408
- function machineFields(machine) {
2577
+ function machineFields(machine, etag = null) {
2409
2578
  return fields([
2410
2579
  ["Name", machine.name],
2411
2580
  ["ID", machine.id],
2412
2581
  ["Region", machine.region],
2413
2582
  ["State", machineState(machine)],
2414
- ["Power", machine.status.power],
2583
+ ["Power", powerText(machine)],
2584
+ ...activationFields(machine),
2585
+ ...conditionFields(machine),
2586
+ ["Sleeps", sleepText(machine)],
2415
2587
  ["Shape", machine.shape],
2416
2588
  ["Image", imageText(machine)],
2417
2589
  ...imageFailureFields(machine),
2418
2590
  ["Command", machine.command ? machine.command.join(" ") : "the image's own"],
2419
2591
  ["Ports", machinePortsText(machine)],
2420
2592
  ["HTTPS", machine.hostnames.https],
2421
- ["SSH", machine.hostnames.ssh],
2593
+ // The resource keeps the SSH destination as a receipt; SSH is not served
2594
+ // before tier P3.
2595
+ ["SSH", `${machine.hostnames.ssh} (not served yet)`],
2422
2596
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
2423
- ["Sleep after", `${machine.sleep_after_seconds}s without a connection`],
2424
- ["Idle action", idleActionText(machine)],
2425
- ["Stop grace", `${machine.stop_grace_seconds}s`],
2426
- ["Database", machine.database.name],
2597
+ ["Stop grace", `${machine.stop_grace_seconds} s`],
2598
+ ["Database", `${machine.database.name} (${machine.database.url})`],
2599
+ ["Logs", machine.logs.url],
2600
+ ["Durability", DURABILITY_LINK],
2427
2601
  ["Revision", String(machine.revision)],
2602
+ ...etag ? [["ETag", etag]] : [],
2428
2603
  ["Created", machine.created_at],
2429
2604
  ["Updated", machine.updated_at]
2430
2605
  ]);
2431
2606
  }
2607
+ function machineText(machine, etag = null) {
2608
+ return [machineFields(machine, etag), ...privatePortLines(machine)].join("\n");
2609
+ }
2432
2610
  function keyStateText(machine) {
2433
2611
  const state = machine.byoc.key_state;
2434
2612
  if (state === "active") return state;
@@ -2452,10 +2630,10 @@ function deviceFields(machine) {
2452
2630
  ["Updated", machine.updated_at]
2453
2631
  ]);
2454
2632
  }
2455
- function anyMachineFields(machine) {
2456
- return machine.kind === "byoc" ? deviceFields(machine) : machineFields(machine);
2633
+ function anyMachineText(machine, etag = null) {
2634
+ return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag);
2457
2635
  }
2458
- function parsePorts(specs, current) {
2636
+ function parsePorts(specs) {
2459
2637
  const accesses = machinePortAccess;
2460
2638
  return specs.map((spec) => {
2461
2639
  const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
@@ -2464,14 +2642,7 @@ function parsePorts(specs, current) {
2464
2642
  throw new Error(
2465
2643
  `--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
2466
2644
  );
2467
- const port = Number(match[1]);
2468
- const before = current.find((entry) => entry.port === port && entry.access === access);
2469
- return {
2470
- port,
2471
- access,
2472
- default: false,
2473
- ...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
2474
- };
2645
+ return { port: Number(match[1]), access, default: false };
2475
2646
  });
2476
2647
  }
2477
2648
  function withDefault(ports, defaultPort) {
@@ -2493,6 +2664,42 @@ function parseEnv(pairs) {
2493
2664
  }
2494
2665
  return env;
2495
2666
  }
2667
+ var SECRET_LOOKING = /(_KEY|_TOKEN)$|SECRET|PASSWORD/i;
2668
+ function specWarnings(deps, ports, envKeys) {
2669
+ if (ports.length === 0)
2670
+ deps.notice(
2671
+ "This Machine has no port: until exec and SSH arrive, nothing can wake it. Add --port <port>."
2672
+ );
2673
+ const secret = envKeys.filter((key) => SECRET_LOOKING.test(key)).sort();
2674
+ if (secret.length)
2675
+ deps.notice(
2676
+ `${secret.join(", ")} ${secret.length === 1 ? "looks" : "look"} secret: env values are readable by every member through the API; Machine secrets come later.`
2677
+ );
2678
+ }
2679
+ function waitTimeout(options) {
2680
+ if (options.timeout !== void 0 && !options.wait) throw new Error("--timeout needs --wait.");
2681
+ return options.timeout ?? DEFAULT_WAIT_SECONDS;
2682
+ }
2683
+ var DEFAULT_WAIT_SECONDS = 300;
2684
+ async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
2685
+ const now = deps.now ?? Date.now;
2686
+ const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
2687
+ const start = now();
2688
+ let wait = firstWait;
2689
+ let last = null;
2690
+ for (; ; ) {
2691
+ const elapsed = (now() - start) / 1e3;
2692
+ if (elapsed + wait > timeout) return { machine: null, last, waited: Math.round(elapsed) };
2693
+ await pause(wait * 1e3);
2694
+ const machine = await deps.api.getMachine(key, name);
2695
+ if (machine.kind === "byoc")
2696
+ throw new Error(`${name} is one of your computers, not a Machine.`);
2697
+ if (settled(machine))
2698
+ return { machine, last: machine, waited: Math.round((now() - start) / 1e3) };
2699
+ last = machine;
2700
+ wait = pollAfterSeconds;
2701
+ }
2702
+ }
2496
2703
  async function machineListCommand(deps, options = {}) {
2497
2704
  await machineCommand(async () => {
2498
2705
  const page = await deps.api.listMachines(await deps.auth.apiKey(), {
@@ -2542,47 +2749,146 @@ async function machineListCommand(deps, options = {}) {
2542
2749
  }
2543
2750
  function createdText(machine) {
2544
2751
  if (machine.state === "creating")
2545
- return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`;
2752
+ return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready, or create with --wait.`;
2546
2753
  return `Created Machine ${machine.name}.`;
2547
2754
  }
2755
+ function ago(deps, timestamp) {
2756
+ const seconds = Math.max(
2757
+ 0,
2758
+ Math.round(((deps.now ?? Date.now)() - Date.parse(timestamp)) / 1e3)
2759
+ );
2760
+ if (seconds < 120) return `${seconds} s`;
2761
+ if (seconds < 7200) return `${Math.round(seconds / 60)} min`;
2762
+ if (seconds < 172800) return `${Math.round(seconds / 3600)} h`;
2763
+ return `${Math.round(seconds / 86400)} days`;
2764
+ }
2765
+ async function nameTaken(deps, key, name, error) {
2766
+ let holder;
2767
+ try {
2768
+ holder = await deps.api.getMachine(key, name);
2769
+ } catch {
2770
+ return error;
2771
+ }
2772
+ const what = holder.kind === "byoc" ? `${name} is one of your computers; choose another name.` : `A Machine named ${name} already exists (created ${ago(deps, holder.created_at)} ago, ${holder.state}); if it is yours from a lost reply, \`tonbo machine show ${name}\`.`;
2773
+ return new HttpError(`${error.message}
2774
+ ${what}`, error.status, error.body, error.requestId);
2775
+ }
2776
+ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json) {
2777
+ const {
2778
+ machine: settled,
2779
+ last,
2780
+ waited
2781
+ } = await waitForMachine(
2782
+ deps,
2783
+ key,
2784
+ name,
2785
+ firstWait,
2786
+ timeout,
2787
+ (read) => read.state !== "creating"
2788
+ );
2789
+ if (!settled) {
2790
+ const known = last ?? machine;
2791
+ print(deps, json, known, machineFields(known));
2792
+ throw new Error(
2793
+ `Machine ${machine.name} is still creating after ${waited} s; nothing was undone. \`tonbo machine show ${machine.name}\` reports it active when it is ready.`
2794
+ );
2795
+ }
2796
+ if (settled.state === "active") {
2797
+ const pulled = settled.image.digest === null ? "" : `, image ${settled.image.state}`;
2798
+ print(
2799
+ deps,
2800
+ json,
2801
+ settled,
2802
+ [
2803
+ `Created ${settled.name} in ${settled.region} (active after ${waited} s${pulled}).`,
2804
+ machineText(settled)
2805
+ ].join("\n")
2806
+ );
2807
+ return;
2808
+ }
2809
+ print(deps, json, settled, machineFields(settled));
2810
+ if (settled.state === "failed") {
2811
+ const { reason, hint } = creationFailureText(settled);
2812
+ throw new Error(
2813
+ `Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
2814
+ );
2815
+ }
2816
+ throw new Error(`Machine ${settled.name} is ${settled.state}; it was not created.`);
2817
+ }
2548
2818
  async function machineCreateCommand(deps, name, command, options = {}) {
2549
2819
  await machineCommand(async () => {
2550
2820
  if (!options.image) throw new Error("Pass --image <reference>.");
2821
+ const timeout = waitTimeout(options);
2551
2822
  const env = parseEnv(options.env ?? []);
2552
- const ports = withDefault(parsePorts(options.port ?? [], []), options.defaultPort);
2823
+ const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
2553
2824
  const key = await deps.auth.apiKey();
2554
2825
  const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
2555
- const machine = await deps.api.createMachine(
2556
- key,
2557
- {
2558
- name,
2559
- image: options.image,
2560
- shape,
2561
- region: options.region,
2562
- command: command.length ? command : void 0,
2563
- ports: ports.length ? ports : void 0,
2564
- env: Object.keys(env).length ? env : void 0,
2565
- sleep_after_seconds: options.sleepAfter,
2566
- idle_action: options.idleAction,
2567
- stop_grace_seconds: options.stopGrace
2568
- },
2569
- options.idempotencyKey ?? randomUUID3()
2570
- );
2826
+ let created;
2827
+ try {
2828
+ created = await deps.api.createMachine(
2829
+ key,
2830
+ {
2831
+ name,
2832
+ image: options.image,
2833
+ shape,
2834
+ region: options.region,
2835
+ command: command.length ? command : void 0,
2836
+ ports: ports.length ? ports : void 0,
2837
+ env: Object.keys(env).length ? env : void 0,
2838
+ sleep_after_seconds: options.sleepAfter,
2839
+ idle_action: options.idleAction,
2840
+ stop_grace_seconds: options.stopGrace
2841
+ },
2842
+ options.idempotencyKey ?? randomUUID3()
2843
+ );
2844
+ } catch (error) {
2845
+ if (error instanceof HttpError && error.code === "name_taken")
2846
+ throw await nameTaken(deps, key, name, error);
2847
+ throw error;
2848
+ }
2849
+ const { machine } = created;
2850
+ specWarnings(deps, machine.ports, Object.keys(env));
2571
2851
  if (machine.state === "failed") {
2572
2852
  print(deps, options.json, machine, machineFields(machine));
2853
+ const { reason, hint } = creationFailureText(machine);
2573
2854
  throw new Error(
2574
- `Machine ${machine.name} failed to be created; delete it with \`tonbo machine delete ${machine.name}\` and create it again.`
2855
+ `Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
2575
2856
  );
2576
2857
  }
2577
- print(deps, options.json, machine, [createdText(machine), machineFields(machine)].join("\n"));
2858
+ if (options.wait && machine.state === "creating")
2859
+ return waitForCreate(
2860
+ deps,
2861
+ key,
2862
+ machine,
2863
+ deps.api.machineNameAt(created.location) ?? machine.name,
2864
+ created.retryAfterSeconds ?? pollAfterSeconds,
2865
+ timeout,
2866
+ options.json
2867
+ );
2868
+ print(deps, options.json, machine, [createdText(machine), machineText(machine)].join("\n"));
2578
2869
  });
2579
2870
  }
2580
2871
  async function machineShowCommand(deps, name, options = {}) {
2581
2872
  await machineCommand(async () => {
2582
- const machine = await deps.api.getMachine(await deps.auth.apiKey(), name);
2583
- print(deps, options.json, machine, anyMachineFields(machine));
2873
+ const { machine, etag } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
2874
+ print(deps, options.json, machine, anyMachineText(machine, etag));
2584
2875
  });
2585
2876
  }
2877
+ function appliesText(name, change) {
2878
+ const lines = [];
2879
+ if (change.ports !== void 0 || change.sleep_after_seconds !== void 0)
2880
+ lines.push("Ports and sleep-after apply now.");
2881
+ const later = [change.image, change.command, change.shape, change.env].some(
2882
+ (field) => field !== void 0
2883
+ );
2884
+ if (later) {
2885
+ lines.push(
2886
+ `The change applies at the next activation. To apply it now, run \`tonbo machine sleep ${name}\`; the next request boots the new spec.`
2887
+ );
2888
+ if (change.image !== void 0) lines.push("Naming the same tag again re-resolves its digest.");
2889
+ }
2890
+ return lines;
2891
+ }
2586
2892
  async function machineUpdateCommand(deps, name, command, options = {}) {
2587
2893
  await machineCommand(async () => {
2588
2894
  if (command.length && options.imageCommand)
@@ -2590,12 +2896,14 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
2590
2896
  if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
2591
2897
  throw new Error("Give --port or --clear-ports, not both.");
2592
2898
  const key = await deps.auth.apiKey();
2593
- const read = await deps.api.getMachine(key, name);
2899
+ const { machine: read, etag: readTag } = await deps.api.getMachineWithEtag(key, name);
2594
2900
  if (read.kind === "byoc")
2595
2901
  throw new Error(
2596
2902
  `${name} is one of your computers; only \`tonbo machine show\` and \`tonbo machine delete\` serve it.`
2597
2903
  );
2598
2904
  const current = read;
2905
+ const etag = ifMatchHeader(options.ifMatch) ?? readTag;
2906
+ if (etag === null) throw new Error(`The API answered ${name} without an ETag.`);
2599
2907
  const change = {
2600
2908
  image: options.image,
2601
2909
  shape: options.shape,
@@ -2606,59 +2914,90 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
2606
2914
  };
2607
2915
  if (options.clearPorts) change.ports = [];
2608
2916
  else if (options.port?.length) {
2609
- const ports = parsePorts(options.port, current.ports);
2917
+ const ports = parsePorts(options.port);
2610
2918
  const kept = current.ports.find((entry) => entry.default)?.port;
2611
2919
  const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
2612
2920
  change.ports = withDefault(ports, defaultPort);
2613
2921
  } else if (options.defaultPort !== void 0)
2614
2922
  change.ports = withDefault(current.ports, options.defaultPort);
2923
+ const setEnv = parseEnv(options.env ?? []);
2615
2924
  if (options.env?.length || options.unsetEnv?.length) {
2616
- const env = { ...current.env, ...parseEnv(options.env ?? []) };
2925
+ const env = { ...current.env, ...setEnv };
2617
2926
  for (const variable of options.unsetEnv ?? []) delete env[variable];
2618
2927
  change.env = env;
2619
2928
  }
2620
2929
  if (options.discardSnapshot) change.discard_snapshot = true;
2621
2930
  if (Object.values(change).every((value) => value === void 0))
2622
2931
  throw new Error("Nothing to change: pass at least one option or a command.");
2623
- const machine = await deps.api.updateMachine(
2624
- key,
2625
- name,
2626
- options.revision ?? current.revision,
2627
- change
2628
- );
2932
+ const machine = await deps.api.updateMachine(key, name, etag, change);
2933
+ specWarnings(deps, machine.ports, Object.keys(setEnv));
2629
2934
  print(
2630
2935
  deps,
2631
2936
  options.json,
2632
2937
  machine,
2633
2938
  [
2634
- `Updated Machine ${machine.name} to revision ${machine.revision}. Ports and sleep-after apply now; image, command, shape and env at its next boot.`,
2635
- machineFields(machine)
2939
+ `Updated Machine ${machine.name} to revision ${machine.revision}.`,
2940
+ ...appliesText(machine.name, change),
2941
+ machineText(machine)
2636
2942
  ].join("\n")
2637
2943
  );
2638
2944
  });
2639
2945
  }
2946
+ function sleptText(machine) {
2947
+ if (machine.state === "creating")
2948
+ return `Machine ${machine.name} is still creating; nothing to put to sleep.`;
2949
+ if (machine.status.power === "asleep")
2950
+ return `Machine ${machine.name} is already asleep; nothing to do. The next request boots it.`;
2951
+ return `Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); the next request boots it again, on its latest spec.`;
2952
+ }
2640
2953
  async function machineSleepCommand(deps, name, options = {}) {
2641
2954
  await machineCommand(async () => {
2642
- const machine = await deps.api.sleepMachine(await deps.auth.apiKey(), name);
2955
+ const timeout = waitTimeout(options);
2956
+ const key = await deps.auth.apiKey();
2957
+ const machine = await deps.api.sleepMachine(key, name, ifMatchHeader(options.ifMatch));
2958
+ const going = options.wait && machine.state === "active" && machine.status.power !== "asleep";
2959
+ if (!going) {
2960
+ print(deps, options.json, machine, sleptText(machine));
2961
+ return;
2962
+ }
2963
+ const { machine: asleep, waited } = await waitForMachine(
2964
+ deps,
2965
+ key,
2966
+ name,
2967
+ pollAfterSeconds,
2968
+ timeout,
2969
+ (read) => read.status.power === "asleep" || read.state !== "active"
2970
+ );
2971
+ if (!asleep)
2972
+ throw new Error(
2973
+ `Machine ${name} is not asleep after ${waited} s; \`tonbo machine show ${name}\` says what keeps it awake.`
2974
+ );
2975
+ if (asleep.status.power !== "asleep")
2976
+ throw new Error(`Machine ${name} is ${asleep.state}; it did not go to sleep.`);
2643
2977
  print(
2644
2978
  deps,
2645
2979
  options.json,
2646
- machine,
2647
- `Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); its next connection wakes it.`
2980
+ asleep,
2981
+ `Machine ${asleep.name} is asleep (after ${waited} s); the next request boots it, on its latest spec.`
2648
2982
  );
2649
2983
  });
2650
2984
  }
2651
2985
  async function machineDeleteCommand(deps, name, options = {}) {
2652
2986
  await machineCommand(async () => {
2653
2987
  const key = await deps.auth.apiKey();
2988
+ const read = { etag: null };
2654
2989
  await confirm(
2655
2990
  deps,
2656
2991
  options.yes,
2657
2992
  `delete Machine ${name}`,
2658
- () => deleteQuestion(deps, key, name),
2993
+ () => deleteQuestion(deps, key, name, read),
2659
2994
  name
2660
2995
  );
2661
- const { machine, kind } = await deps.api.deleteMachine(key, name);
2996
+ const { machine, kind } = await deps.api.deleteMachine(
2997
+ key,
2998
+ name,
2999
+ ifMatchHeader(options.ifMatch) ?? read.etag ?? void 0
3000
+ );
2662
3001
  print(deps, options.json, machine, deletedText(name, machine, kind));
2663
3002
  if (machine?.kind === "byoc" && machine.byoc.key_state === "active")
2664
3003
  deps.notice(
@@ -2666,10 +3005,12 @@ async function machineDeleteCommand(deps, name, options = {}) {
2666
3005
  );
2667
3006
  });
2668
3007
  }
2669
- async function deleteQuestion(deps, key, name) {
3008
+ async function deleteQuestion(deps, key, name, read) {
2670
3009
  let kind = null;
2671
3010
  try {
2672
- kind = (await deps.api.getMachine(key, name)).kind;
3011
+ const answer = await deps.api.getMachineWithEtag(key, name);
3012
+ kind = answer.machine.kind;
3013
+ read.etag = answer.etag;
2673
3014
  } catch (error) {
2674
3015
  if (!(error instanceof HttpError && error.status === 404)) throw error;
2675
3016
  }
@@ -2686,6 +3027,9 @@ function deletedText(name, machine, kind) {
2686
3027
  return `Machine ${machine.name} is being deleted; its name is free for a new Machine.`;
2687
3028
  return `Released the name ${name}; no Machine had been created under it.`;
2688
3029
  }
3030
+ function hourlyPrice(micros) {
3031
+ return `$${(micros / 1e6).toFixed(4)}/h`;
3032
+ }
2689
3033
  async function machineShapesCommand(deps, options = {}) {
2690
3034
  await machineCommand(async () => {
2691
3035
  const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
@@ -2694,14 +3038,15 @@ async function machineShapesCommand(deps, options = {}) {
2694
3038
  options.json,
2695
3039
  shapes,
2696
3040
  table(
2697
- ["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
3041
+ ["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE", "LIST PRICE"],
2698
3042
  shapes.data.map((shape) => [
2699
3043
  shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
2700
3044
  String(shape.vcpus),
2701
3045
  `${shape.memory_gib} GiB`,
2702
3046
  `${shape.scratch_gib} GiB`,
2703
3047
  shape.offered ? "yes" : "no",
2704
- shape.suspendable ? "yes" : "no"
3048
+ shape.suspendable ? "yes" : "no",
3049
+ hourlyPrice(shape.ceiling_usd_micros_per_hour)
2705
3050
  ])
2706
3051
  )
2707
3052
  );
@@ -2768,6 +3113,7 @@ function operationOptions(command, operationId) {
2768
3113
  command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
2769
3114
  return command.option("--json", "print the API data as JSON");
2770
3115
  }
3116
+ var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes); the ETag the command reads, if it reads one, by default`;
2771
3117
  var repeated = (value, previous = []) => [...previous, value];
2772
3118
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
2773
3119
  function machineSpecOptions(command, update) {
@@ -3075,6 +3421,10 @@ function createProgram(dependencies = createDependencies) {
3075
3421
  ).option(
3076
3422
  "--idempotency-key <key>",
3077
3423
  "reuse the key of an unconfirmed creation to get the Machine it created"
3424
+ ).option("--wait", "wait until the Machine is active, or say why it failed").option(
3425
+ "--timeout <seconds>",
3426
+ `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3427
+ parseWhole
3078
3428
  ),
3079
3429
  "createMachine"
3080
3430
  ).action(
@@ -3090,11 +3440,7 @@ function createProgram(dependencies = createDependencies) {
3090
3440
  machineSpecOptions(
3091
3441
  machine.command("update <name> [command...]").description(
3092
3442
  "change a Machine; [command...] after -- replaces its command at the next boot"
3093
- ).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(
3094
- "--revision <revision>",
3095
- "write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
3096
- parseWhole
3097
- ).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
3443
+ ).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("--if-match <etag>", ifMatchHelp("change")).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
3098
3444
  true
3099
3445
  ),
3100
3446
  "updateMachine"
@@ -3102,7 +3448,11 @@ function createProgram(dependencies = createDependencies) {
3102
3448
  async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
3103
3449
  );
3104
3450
  operationOptions(
3105
- machine.command("sleep <name>").description("put a Machine to sleep now"),
3451
+ machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option(
3452
+ "--timeout <seconds>",
3453
+ `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3454
+ parseWhole
3455
+ ),
3106
3456
  "sleepMachine"
3107
3457
  ).action(
3108
3458
  async (name, options) => machineSleepCommand(dependencies(), name, options)
@@ -3110,7 +3460,7 @@ function createProgram(dependencies = createDependencies) {
3110
3460
  operationOptions(
3111
3461
  machine.command("delete <name>").description(
3112
3462
  "delete a Machine with its disk, database and logs, or forget one of your computers"
3113
- ).option("--yes", "delete without asking; required when stdin is not a terminal"),
3463
+ ).option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
3114
3464
  "deleteMachine"
3115
3465
  ).action(
3116
3466
  async (name, options) => machineDeleteCommand(dependencies(), name, options)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.8.0",
3
+ "version": "0.9.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": {