@tonbo/cli 0.21.0 → 0.23.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
@@ -26,7 +26,7 @@ The login file, `login.json`, and the device id it is tied to are shared with [`
26
26
 
27
27
  ## Commands and the Management API
28
28
 
29
- 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, the activations `machine show` reads for the last end, or the Volume a `machine create` or `update --volume` names, read for its id and region); `tonbo machine logs`, once it ships, is the named Streams exception, reading the stream at the Machine's `logs.url`. `tonbo volume move` is the one command that sends another command's operation: it reads the Volume and both Machines and sends the target's `updateMachine` with `move_from` (the `machine create --move-from` of an existing Machine, docs/design/machines-volume-move.md). Apart from it, 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`, `logout`, `account list` and `account use` are the named exception: they obtain, choose among and discard what the other commands present as their Bearer credential. What a command may do is what your role in the account it acts in allows; the CLI never decides it.
29
+ 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, the activations `machine show` reads for the last end, the Volume a `machine create` or `update --volume` names, read for its id and region, or the Volume list `machine show` and `machine delete` read for the name of the Machine's Volume); `tonbo machine logs`, once it ships, is the named Streams exception, reading the stream at the Machine's `logs.url`. `tonbo volume move` is the one command that sends another command's operation: it reads the Volume and both Machines and sends the target's `updateMachine` with `move_from` (the `machine create --move-from` of an existing Machine, docs/design/machines-volume-move.md). Apart from it, 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`, `logout`, `account list` and `account use` are the named exception: they obtain, choose among and discard what the other commands present as their Bearer credential. What a command may do is what your role in the account it acts in allows; the CLI never decides it.
30
30
 
31
31
  The help lives with the commands: [`src/app.ts`](src/app.ts) defines each command, its arguments and flags with their one-line descriptions, and [`src/help.ts`](src/help.ts) holds the long help, each flag's default, the examples, the shared rules and the environment. [`commands.json`](commands.json) is all of it as `--help` prints it, which the docs' CLI reference renders; `test/commands.test.ts` holds it to the program, and holds every runnable command to its details and example and the environment list to the variables the code reads, so after changing a command run `pnpm --filter @tonbo/cli commands`.
32
32
 
package/dist/bin/tonbo.js CHANGED
@@ -673,7 +673,9 @@ var managementOperations = {
673
673
  "pathParams": [
674
674
  "name"
675
675
  ],
676
- "query": [],
676
+ "query": [
677
+ "delete_volume"
678
+ ],
677
679
  "idempotencyKey": false,
678
680
  "cli": "tonbo machine delete"
679
681
  },
@@ -687,6 +689,16 @@ var managementOperations = {
687
689
  "idempotencyKey": false,
688
690
  "cli": "tonbo machine sleep"
689
691
  },
692
+ "rebuildMachine": {
693
+ "method": "POST",
694
+ "path": "/machines/{name}/rebuild",
695
+ "pathParams": [
696
+ "name"
697
+ ],
698
+ "query": [],
699
+ "idempotencyKey": false,
700
+ "cli": "tonbo machine rebuild"
701
+ },
690
702
  "wakeMachine": {
691
703
  "method": "POST",
692
704
  "path": "/machines/{name}/wake",
@@ -1941,6 +1953,10 @@ var machineTimings = {
1941
1953
  "wakeHoldSeconds": 25,
1942
1954
  "earlyReclaimSeconds": 120
1943
1955
  };
1956
+ var machineRebuildWarning = [
1957
+ "**Rebuild deletes changes outside /home, /workspace and /root.**",
1958
+ "Rebuild moves this Machine to the image `<image>`. Files in /home, /workspace and /root are kept. Everything else you changed on this Machine's disk is deleted and cannot be recovered: installed packages, files you edited in /etc, global npm or pip packages, and anything in /usr, /opt or /var. To keep those changes, add them to the image's Dockerfile. The Machine sleeps now and starts on the new image at its next wake."
1959
+ ];
1944
1960
  var machineSecretLimits = {
1945
1961
  "perMachine": 64,
1946
1962
  "valueMaxBytes": 65536,
@@ -2483,6 +2499,36 @@ var ManagementApi = class {
2483
2499
  const answer = await this.request("updateMachine", key, { params: { name }, body: withoutUndefined(change), ifMatch: etag });
2484
2500
  return { machine: answer.data, volumeMove: answer.volume_move ?? null };
2485
2501
  }
2502
+ /** A Rebuild (machine-v2.json `rebuild`): `POST /machines/{name}/rebuild`
2503
+ * with `image`, or the Machine's own reference resolved again without it.
2504
+ * A 202 (`waiting_for_sleep`) carries the `Retry-After` to read the Machine
2505
+ * again after; the request is then sent again once it is asleep. With
2506
+ * `etag`, only the Machine that ETag names is rebuilt
2507
+ * (machine-v2.json `etag.if_match.rebuild` is optional). */
2508
+ async rebuildMachine(key, name, request, etag) {
2509
+ const headers = { retryAfter: null };
2510
+ const answer = await this.request("rebuildMachine", key, { params: { name }, body: withoutUndefined(request), ...etag ? { ifMatch: etag } : {} }, (response) => {
2511
+ headers.retryAfter = response.headers.get("retry-after");
2512
+ });
2513
+ return {
2514
+ machine: answer.data,
2515
+ rebuild: answer.rebuild,
2516
+ retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
2517
+ };
2518
+ }
2519
+ /** An update with `rebuild: true` and `image`, which answers as
2520
+ * rebuildMachine does (machine-v2.json `rebuild.answers`). */
2521
+ async updateMachineRebuild(key, name, etag, change) {
2522
+ const headers = { retryAfter: null };
2523
+ const answer = await this.request("updateMachine", key, { params: { name }, body: withoutUndefined({ ...change, rebuild: true }), ifMatch: etag }, (response) => {
2524
+ headers.retryAfter = response.headers.get("retry-after");
2525
+ });
2526
+ return {
2527
+ machine: answer.data,
2528
+ rebuild: answer.rebuild,
2529
+ retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
2530
+ };
2531
+ }
2486
2532
  /** With `etag`, only the Machine that ETag names, at that revision, is put
2487
2533
  * to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
2488
2534
  * carries the `Retry-After` to read the Machine again after
@@ -2511,14 +2557,25 @@ var ManagementApi = class {
2511
2557
  * says whether a name was released (`managed`: its create never reached its
2512
2558
  * region) or a device was forgotten (`byoc`: its region held no row). With
2513
2559
  * `etag`, only the Machine that ETag names is deleted
2514
- * (machine-v2.json `etag.if_match.delete` is optional). */
2515
- async deleteMachine(key, name, etag) {
2560
+ * (machine-v2.json `etag.if_match.delete` is optional). `deleteVolume`
2561
+ * decides the Volume a managed Machine holds (machine-v2.json
2562
+ * `delete_volume`); without it the server's default applies, and a 202
2563
+ * says in `volume` what became of it. */
2564
+ async deleteMachine(key, name, etag, deleteVolume) {
2516
2565
  const headers = { kind: null };
2517
- const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
2566
+ const answer = await this.request("deleteMachine", key, {
2567
+ params: { name },
2568
+ query: { delete_volume: deleteVolume === void 0 ? void 0 : String(deleteVolume) },
2569
+ ...etag ? { ifMatch: etag } : {}
2570
+ }, (response) => {
2518
2571
  headers.kind = response.headers.get("x-tonbo-machine-kind");
2519
2572
  });
2520
2573
  if (answer?.data)
2521
- return { machine: answer.data, kind: answer.data.kind };
2574
+ return {
2575
+ machine: answer.data,
2576
+ kind: answer.data.kind,
2577
+ ...answer.volume === void 0 ? {} : { volume: answer.volume }
2578
+ };
2522
2579
  return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
2523
2580
  }
2524
2581
  /** One page of a managed Machine's activations, newest first; the cursor
@@ -5597,18 +5654,6 @@ function volumeFields(volume) {
5597
5654
  ["Size", `${volume.size_gib} GiB`],
5598
5655
  ["Live bytes", `${bytes(volume.live_bytes)} (billed)`],
5599
5656
  ["Last meter report", volume.last_commit_at ?? "none yet"],
5600
- [
5601
- "Restored to",
5602
- volume.restored_to ? `${volume.restored_to.committed_at} (generation ${volume.restored_to.data_generation})` : "none"
5603
- ],
5604
- [
5605
- "Backed up to",
5606
- volume.backed_up_to ? volume.backed_up_to.committed_at ? `${volume.backed_up_to.committed_at} (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : `empty snapshot (generation ${volume.backed_up_to.data_generation}; ${volume.backed_up_to.dirty_bytes} bytes pending copy)` : "unknown"
5607
- ],
5608
- [
5609
- "Cold GETs",
5610
- volume.cold ? `${volume.cold.demand_gets} demand, ${volume.cold.hydrate_gets} hydration` : "none"
5611
- ],
5612
5657
  ["Durability", VOLUMES_PREVIEW ? VOLUME_PREVIEW_TEXT : VOLUME_DURABILITY_LINK],
5613
5658
  ["Created", volume.created_at]
5614
5659
  ]);
@@ -5789,12 +5834,13 @@ var MACHINE_ERROR_HINTS = {
5789
5834
  machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
5790
5835
  volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed.",
5791
5836
  root_mode_not_offered: "A Volume as the Machine's whole disk is not offered to this account yet; nothing was created or changed. Mount it at a path instead: --volume <name>:<path>.",
5792
- rebuild_required: "This Machine's Volume is its whole disk, so its image changes only by Rebuild, which keeps /home, /workspace and /root and deletes every other change on the disk. Rebuild is not available yet: keep the image, or create a new Machine with the new image and a new Volume.",
5793
- volume_mode_change: "The Volume cannot move between being the Machine's whole disk and being mounted at a path; nothing was changed. Bind a new Volume in the mode you want.",
5837
+ rebuild_required: "This Machine's Volume is its whole disk, so its image changes only by Rebuild; nothing was changed. Once you have read what Rebuild deletes, run `tonbo machine rebuild <name> --image <reference>`, or the same update with --rebuild.",
5838
+ volume_not_whole_disk: "This Machine's Volume is mounted at a path, or it has none, so it needs no Rebuild; nothing was changed. Change its image with `tonbo machine update <name> --image <reference>`.",
5839
+ volume_mode_change: "A Machine's whole disk is never mounted at a path, and a Volume mounted at a path becomes a whole disk only on its own Machine; nothing was changed.",
5794
5840
  volume_attached: "The Machine the message names is awake with the Volume attached; nothing was changed. Put it to sleep with `tonbo machine sleep <machine>`, then run the command again.",
5795
5841
  volume_holder_changed: "The Volume is not on the Machine you named; nothing was changed. `tonbo volume show <volume>` says which Machine has it: name that one, or use `tonbo volume move`.",
5796
5842
  volume_image_mismatch: "The Volume is a Machine's whole disk, made against one image; it moves only to a Machine on that same image. Nothing was changed.",
5797
- volume_rebuild_pending: "The Volume's Rebuild has not finished; move it once it has. Nothing was changed.",
5843
+ volume_rebuild_pending: "The Volume's Rebuild is recorded and applies at its Machine's next wake: wake that Machine once (`tonbo machine wake <machine>`), then move the Volume. Nothing was changed.",
5798
5844
  secret_env_conflict: "Use another secret key or remove the non-secret env variable with that name.",
5799
5845
  secret_limit: "Reduce the number or byte size of this Machine's secrets.",
5800
5846
  wake_unavailable: "The wake was refused for now; nothing woke. Wake it again after a while; `tonbo machine show` says why it is blocked."
@@ -5914,7 +5960,8 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
5914
5960
  capacity: "No host had room for its shape: Tonbo is adding a host, <minutes>, and the next request after that wakes it.",
5915
5961
  region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it.",
5916
5962
  budget_slowdown: "Its budget is close to running out, and this wake would pass the budget's slow-down rate: sleep another Machine of the budget, or lower this one's shape, then wake it again. Machines already awake keep running.",
5917
- budget_exhausted: "Its budget is used up for the month: it wakes after the budget resets, or once a person raises it in the console."
5963
+ budget_exhausted: "Its budget is used up for the month: it wakes after the budget resets, or once a person raises it in the console.",
5964
+ root_image_mismatch: "Its disk was made over another image than the Machine's: run `tonbo machine rebuild <name>` to move the disk to the Machine's image (Rebuild deletes changes outside /home, /workspace and /root)."
5918
5965
  };
5919
5966
  var MACHINE_ACCOUNT_EGRESS_BLOCKED_HINT = "If this is the free egress cap: 10 GiB a month without purchased credit; buy credit at <billing> to lift it.";
5920
5967
  function minutesUntil(retryAt, now = Date.now()) {
@@ -5987,7 +6034,7 @@ function imageFailureFields(machine) {
5987
6034
  ...next ? [["Next step", next]] : []
5988
6035
  ];
5989
6036
  }
5990
- function machineFields(machine, billing, etag = null, lastEnd = null) {
6037
+ function machineFields(machine, billing, etag = null, lastEnd = null, volumeName2 = null) {
5991
6038
  return fields([
5992
6039
  ["Name", machine.name],
5993
6040
  ["ID", machine.id],
@@ -6010,7 +6057,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
6010
6057
  ["SSH", `${machine.hostnames.ssh} (tonbo ssh ${machine.name})`],
6011
6058
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
6012
6059
  ["Stop grace", `${machine.stop_grace_seconds} s`],
6013
- ["Volume", volumeText(machine)],
6060
+ ["Volume", volumeText(machine, volumeName2)],
6014
6061
  ...deleteFields(machine),
6015
6062
  ["Database", machine.database ? `${machine.database.name} (${machine.database.url})` : "none"],
6016
6063
  ["Logs", machine.logs.url],
@@ -6055,13 +6102,28 @@ function deleteFields(machine) {
6055
6102
  ]
6056
6103
  ];
6057
6104
  }
6058
- function volumeText(machine) {
6105
+ function volumeText(machine, name = null) {
6059
6106
  const binding = machine.volumes?.[0];
6060
6107
  if (!binding) return "none";
6061
- return binding.path === void 0 ? `${binding.volume} (whole disk)` : `${binding.volume} at ${binding.path}`;
6108
+ if (name === null)
6109
+ return binding.path === void 0 ? `${binding.volume} (whole disk)` : `${binding.volume} at ${binding.path}`;
6110
+ return binding.path === void 0 ? `${name} (whole disk; id ${binding.volume})` : `${name} at ${binding.path} (id ${binding.volume})`;
6062
6111
  }
6063
- function machineText(machine, billing, etag = null, lastEnd = null) {
6064
- return [machineFields(machine, billing, etag, lastEnd), ...privatePortLines(machine)].join("\n");
6112
+ async function volumeName(deps, key, machine) {
6113
+ const id = machine.volumes?.[0]?.volume;
6114
+ if (id === void 0) return null;
6115
+ try {
6116
+ return (await deps.api.listVolumes(key)).data.find((volume) => volume.id === id)?.name ?? null;
6117
+ } catch (error) {
6118
+ if (!(error instanceof HttpError || error instanceof AccessTokenError)) throw error;
6119
+ return null;
6120
+ }
6121
+ }
6122
+ function machineText(machine, billing, etag = null, lastEnd = null, volume = null) {
6123
+ return [
6124
+ machineFields(machine, billing, etag, lastEnd, volume),
6125
+ ...privatePortLines(machine)
6126
+ ].join("\n");
6065
6127
  }
6066
6128
  function keyStateText(machine) {
6067
6129
  const state = machine.byoc.key_state;
@@ -6086,8 +6148,8 @@ function deviceFields(machine) {
6086
6148
  ["Updated", machine.updated_at]
6087
6149
  ]);
6088
6150
  }
6089
- function anyMachineText(machine, billing, etag = null, lastEnd = null) {
6090
- return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, billing, etag, lastEnd);
6151
+ function anyMachineText(machine, billing, etag = null, lastEnd = null, volume = null) {
6152
+ return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, billing, etag, lastEnd, volume);
6091
6153
  }
6092
6154
  function parsePorts(specs) {
6093
6155
  const accesses = machinePortAccess;
@@ -6456,11 +6518,15 @@ async function machineShowCommand(deps, name, options = {}) {
6456
6518
  ...processes.running.map((line) => `Background process: ${line}`),
6457
6519
  ...processes.last ? [`Last background process: ${processes.last}`] : []
6458
6520
  ] : [];
6521
+ const volume = options.json || machine.kind === "byoc" ? null : await volumeName(deps, key, machine);
6459
6522
  print(
6460
6523
  deps,
6461
6524
  options.json,
6462
6525
  machine,
6463
- [anyMachineText(machine, await billingUrl(deps), etag, lastEnd), ...processLines].join("\n")
6526
+ [
6527
+ anyMachineText(machine, await billingUrl(deps), etag, lastEnd, volume),
6528
+ ...processLines
6529
+ ].join("\n")
6464
6530
  );
6465
6531
  });
6466
6532
  }
@@ -6544,6 +6610,8 @@ function appliesText(name, change) {
6544
6610
  }
6545
6611
  async function machineUpdateCommand(deps, name, command, options = {}) {
6546
6612
  await machineCommand(async () => {
6613
+ if (options.rebuild && options.image === void 0)
6614
+ throw new Error("--rebuild needs --image: it moves the Machine's whole disk to that image.");
6547
6615
  if (command.length && options.imageCommand)
6548
6616
  throw new Error("Give a command or --image-command, not both.");
6549
6617
  if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
@@ -6588,7 +6656,27 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
6588
6656
  if (options.discardSnapshot) change.discard_snapshot = true;
6589
6657
  if (Object.values(change).every((value) => value === void 0))
6590
6658
  throw new Error("Nothing to change: pass at least one option or a command.");
6591
- const machine = await deps.api.updateMachine(key, name, etag, change);
6659
+ const rebuild = () => rebuildUntilDone(
6660
+ deps,
6661
+ key,
6662
+ name,
6663
+ DEFAULT_WAIT_SECONDS,
6664
+ () => deps.api.updateMachineRebuild(key, name, etag, change)
6665
+ );
6666
+ let rebuilt = options.rebuild ? await rebuild() : null;
6667
+ let machine = rebuilt?.machine;
6668
+ if (!machine)
6669
+ try {
6670
+ machine = await deps.api.updateMachine(key, name, etag, change);
6671
+ } catch (error) {
6672
+ if (!(error instanceof HttpError && error.code === "rebuild_required" && deps.interactive()))
6673
+ throw error;
6674
+ const question = `${rebuildWarning(options.image ?? current.image.reference)}
6675
+ This Machine's Volume is its whole disk, so its image changes only by Rebuild. Type the Machine's name to rebuild it with this change:`;
6676
+ if (await deps.ask(question) !== name) throw new Error("Cancelled; nothing was changed.");
6677
+ rebuilt = await rebuild();
6678
+ machine = rebuilt.machine;
6679
+ }
6592
6680
  specWarnings(deps, machine.ports, Object.keys(setEnv));
6593
6681
  print(
6594
6682
  deps,
@@ -6596,12 +6684,80 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
6596
6684
  machine,
6597
6685
  [
6598
6686
  `Updated Machine ${machine.name} to revision ${machine.revision}.`,
6599
- ...appliesText(machine.name, change),
6687
+ ...rebuilt ? [rebuiltText(machine.name, rebuilt.rebuild)] : appliesText(machine.name, change),
6600
6688
  machineText(machine, await billingUrl(deps))
6601
6689
  ].join("\n")
6602
6690
  );
6603
6691
  });
6604
6692
  }
6693
+ function rebuildWarning(image) {
6694
+ return machineRebuildWarning.map((paragraph) => paragraph.replaceAll("**", "").replaceAll("<image>", image)).join("\n");
6695
+ }
6696
+ function rebuiltText(name, answer) {
6697
+ const { reference, digest } = answer.image;
6698
+ const image = digest === null ? reference : `${reference} (${digest})`;
6699
+ if (answer.state === "not_needed")
6700
+ return `Machine ${name}'s disk was never used, so it moves to ${image} with no Rebuild.`;
6701
+ return `Machine ${name} rebuilds onto ${image} at its next wake; files in /home, /workspace and /root are kept.`;
6702
+ }
6703
+ async function rebuildUntilDone(deps, key, name, timeout, send) {
6704
+ const now = deps.now ?? Date.now;
6705
+ const start = now();
6706
+ for (; ; ) {
6707
+ const answer = await send();
6708
+ if (answer.rebuild.state !== "waiting_for_sleep") return answer;
6709
+ deps.notice(
6710
+ `Machine ${name} is awake with its disk attached, so it is going to sleep first; the Rebuild is sent again once it is asleep.`
6711
+ );
6712
+ const { machine: asleep } = await waitForMachine(
6713
+ deps,
6714
+ key,
6715
+ name,
6716
+ answer.retryAfterSeconds ?? sleepPollAfterSeconds,
6717
+ sleepPollAfterSeconds,
6718
+ timeout - (now() - start) / 1e3,
6719
+ (read) => read.status.power === "asleep" || read.state !== "active"
6720
+ );
6721
+ if (!asleep)
6722
+ throw new Error(
6723
+ `Machine ${name} is not asleep after ${Math.round((now() - start) / 1e3)} s, so nothing was rebuilt; run the command again once it sleeps. \`tonbo machine show ${name}\` says what keeps it awake.`
6724
+ );
6725
+ if (asleep.status.power !== "asleep")
6726
+ throw new Error(`Machine ${name} is ${asleep.state}; nothing was rebuilt.`);
6727
+ }
6728
+ }
6729
+ async function machineRebuildCommand(deps, name, options = {}) {
6730
+ await machineCommand(async () => {
6731
+ const key = await deps.auth.apiKey();
6732
+ const { machine: read, etag } = await deps.api.getMachineWithEtag(key, name);
6733
+ if (read.kind === "byoc")
6734
+ throw new Error(
6735
+ `${name} is one of your computers; only \`tonbo machine show\` and \`tonbo machine delete\` serve it.`
6736
+ );
6737
+ const image = options.image ?? read.image.reference;
6738
+ await confirm(
6739
+ deps,
6740
+ options.yes,
6741
+ `rebuild Machine ${name}`,
6742
+ () => Promise.resolve(`${rebuildWarning(image)}
6743
+ Type the Machine's name to rebuild it:`),
6744
+ name
6745
+ );
6746
+ const answer = await rebuildUntilDone(
6747
+ deps,
6748
+ key,
6749
+ name,
6750
+ options.timeout ?? DEFAULT_WAIT_SECONDS,
6751
+ () => deps.api.rebuildMachine(key, name, { image: options.image }, etag ?? void 0)
6752
+ );
6753
+ print(
6754
+ deps,
6755
+ options.json,
6756
+ { data: answer.machine, rebuild: answer.rebuild },
6757
+ rebuiltText(name, answer.rebuild)
6758
+ );
6759
+ });
6760
+ }
6605
6761
  function sleptText(machine) {
6606
6762
  if (machine.state === "creating")
6607
6763
  return `Machine ${machine.name} is still creating; nothing to put to sleep.`;
@@ -6684,43 +6840,61 @@ async function machineWakeCommand(deps, name, options = {}) {
6684
6840
  }
6685
6841
  async function machineDeleteCommand(deps, name, options = {}) {
6686
6842
  await machineCommand(async () => {
6843
+ if (options.keepVolume && options.deleteVolume)
6844
+ throw new Error("Give --keep-volume or --delete-volume, not both.");
6845
+ const deleteVolume = options.deleteVolume ? true : options.keepVolume ? false : void 0;
6687
6846
  const key = await deps.auth.apiKey();
6688
6847
  const read = { etag: null };
6689
6848
  await confirm(
6690
6849
  deps,
6691
6850
  options.yes,
6692
6851
  `delete Machine ${name}`,
6693
- () => deleteQuestion(deps, key, name, read),
6852
+ () => deleteQuestion(deps, key, name, read, deleteVolume),
6694
6853
  name
6695
6854
  );
6696
- const { machine, kind } = await deps.api.deleteMachine(
6855
+ const { machine, kind, volume } = await deps.api.deleteMachine(
6697
6856
  key,
6698
6857
  name,
6699
- ifMatchHeader(options.ifMatch) ?? read.etag ?? void 0
6858
+ ifMatchHeader(options.ifMatch) ?? read.etag ?? void 0,
6859
+ deleteVolume
6860
+ );
6861
+ print(
6862
+ deps,
6863
+ options.json,
6864
+ machine,
6865
+ [deletedText(name, machine, kind), ...volume ? [volumeFateText(volume)] : []].join("\n")
6700
6866
  );
6701
- print(deps, options.json, machine, deletedText(name, machine, kind));
6702
6867
  if (machine?.kind === "byoc" && machine.byoc.key_state === "active")
6703
6868
  deps.notice(
6704
6869
  `Its key is still active, so ${name} registers again at its next start. Run \`yield logout\` on it, or revoke its key with \`tonbo api-key revoke\`, then forget it again.`
6705
6870
  );
6706
6871
  });
6707
6872
  }
6708
- async function deleteQuestion(deps, key, name, read) {
6709
- let kind = null;
6710
- let ownDatabase = false;
6873
+ async function deleteQuestion(deps, key, name, read, deleteVolume) {
6874
+ let machine = null;
6711
6875
  try {
6712
6876
  const answer = await deps.api.getMachineWithEtag(key, name);
6713
- kind = answer.machine.kind;
6714
- ownDatabase = answer.machine.kind === "managed" && !!answer.machine.database?.name.startsWith("m.");
6877
+ machine = answer.machine;
6715
6878
  read.etag = answer.etag;
6716
6879
  } catch (error) {
6717
6880
  if (!(error instanceof HttpError && error.status === 404)) throw error;
6718
6881
  }
6719
- if (kind === "byoc")
6882
+ if (machine?.kind === "byoc")
6720
6883
  return `Forget your computer ${name}? It disappears from your Machines; its key is not revoked. Type its name to confirm:`;
6721
- if (kind === "managed")
6722
- return `Delete Machine ${name} with its disk${ownDatabase ? ", database" : ""} and logs? This cannot be undone. Type the Machine's name to confirm:`;
6723
- return `Delete Machine ${name}? Type its name to confirm:`;
6884
+ if (machine?.kind !== "managed") return `Delete Machine ${name}? Type its name to confirm:`;
6885
+ const ownDatabase = !!machine.database?.name.startsWith("m.");
6886
+ const binding = machine.volumes?.[0];
6887
+ const parts = [
6888
+ ...binding && binding.path === void 0 ? [] : ["disk"],
6889
+ ...ownDatabase ? ["database"] : []
6890
+ ];
6891
+ const what = parts.length ? `${parts.join(", ")} and logs` : "logs";
6892
+ const volume = binding ? `Volume ${await volumeName(deps, key, machine) ?? binding.volume}` : "";
6893
+ const fate = !binding ? "" : deleteVolume === true ? ` Its ${volume} is deleted with it.` : deleteVolume === false ? ` Its ${volume} is kept.` : ` Its ${volume} is deleted with it if it was made for this Machine (as the console makes a new Machine's disk), and kept otherwise; --keep-volume or --delete-volume decides.`;
6894
+ return `Delete Machine ${name} with its ${what}?${fate} This cannot be undone. Type the Machine's name to confirm:`;
6895
+ }
6896
+ function volumeFateText(volume) {
6897
+ return volume.deleted ? `Volume ${volume.name}: deleted with it.` : `Volume ${volume.name}: kept; delete it with \`tonbo volume delete ${volume.name}\`.`;
6724
6898
  }
6725
6899
  function deletedText(name, machine, kind) {
6726
6900
  if (kind === "byoc")
@@ -6850,7 +7024,7 @@ var TOPICS = [
6850
7024
  id: "confirmations",
6851
7025
  title: "Destructive commands",
6852
7026
  text: [
6853
- "`api-key revoke`, `bucket delete`, `database delete`, `stream delete`, `machine delete` and `volume delete` ask before they act when stdin and stderr are a terminal: a revocation is confirmed by typing `yes`, a deletion by typing the resource's name, as in the console. Without a terminal they refuse unless `--yes` is given."
7027
+ "`api-key revoke`, `bucket delete`, `database delete`, `stream delete`, `machine delete`, `machine rebuild` and `volume delete` ask before they act when stdin and stderr are a terminal: a revocation is confirmed by typing `yes`, a deletion or a Rebuild by typing the resource's name, as in the console. Without a terminal they refuse unless `--yes` is given."
6854
7028
  ]
6855
7029
  }
6856
7030
  ];
@@ -7188,7 +7362,7 @@ var COMMAND_HELP = {
7188
7362
  },
7189
7363
  "tonbo machine show": {
7190
7364
  about: [
7191
- "Prints the Machine's state and what it means (an `active` Machine that is asleep boots on its first request), its hostnames, image and whether it is pulled, the live activation and conditions with what to do about each, when it sleeps, the database it binds (or none) and its logs URL, a link to what its disk keeps, its ETag and who created it. For an `active` Machine it also reads the newest activations and prints how the last one ended. When the live activation runs an older revision than the Machine's, it says which changes already apply and that the rest apply after `tonbo machine sleep <name>`.",
7365
+ "Prints the Machine's state and what it means (an `active` Machine that is asleep boots on its first request), its hostnames, image and whether it is pulled, the live activation and conditions with what to do about each, when it sleeps, the database it binds (or none), its Volume by name and id as its whole disk or where it is mounted (or none), its logs URL, a link to what its disk keeps, its ETag and who created it. For an `active` Machine it also reads the newest activations and prints how the last one ended. When the live activation runs an older revision than the Machine's, it says which changes already apply and that the rest apply after `tonbo machine sleep <name>`.",
7192
7366
  "When the default port is private, it prints the `curl` call that reaches it with a personal key. For one of your computers it prints its system, harnesses, hostname and device id, which only you see."
7193
7367
  ],
7194
7368
  examples: ["tonbo machine show web"],
@@ -7219,10 +7393,13 @@ var COMMAND_HELP = {
7219
7393
  "tonbo machine update": {
7220
7394
  about: [
7221
7395
  "Changes only what its options name. It reads the Machine and sends the ETag it read, or `--if-match`'s, so a change someone made in between is refused with `precondition_failed` rather than overwritten; a Machine whose creation failed is refused with `machine_failed`. `--env` and `--unset-env` change single variables; `--port` replaces every port, and the current default port stays the default while it is still listed.",
7222
- "Ports and `--sleep-after` apply at once. The image, command, shape, env, Volume and database apply at the next boot, and the command says how to apply them now: `tonbo machine sleep`, after which the next request boots the new spec. Naming the same tag again resolves its digest again. A Machine whose Volume is its whole disk keeps its image: another image is refused with `rebuild_required`, since only Rebuild, which keeps /home, /workspace and /root and deletes every other change on the disk, may move it, and Rebuild is not available yet. `--database <name>` binds another database of the account in the Machine's region, which no other Machine binds, and `--no-database` binds none; the database it leaves is kept. A Machine created with its own database keeps it.",
7396
+ "Ports and `--sleep-after` apply at once. The image, command, shape, env, Volume and database apply at the next boot, and the command says how to apply them now: `tonbo machine sleep`, after which the next request boots the new spec. Naming the same tag again resolves its digest again. A Machine whose Volume is its whole disk keeps its image: another image is refused with `rebuild_required`, since only Rebuild, which keeps /home, /workspace and /root and deletes every other change on the disk, may move it. At a terminal the CLI then prints what Rebuild deletes and, once you type the Machine's name, sends the change again as a Rebuild; `--rebuild` with `--image` sends it as one at once, as `tonbo machine rebuild` does, and on a Machine whose Volume is mounted at a path, or that has none, it is refused with `volume_not_whole_disk`. `--database <name>` binds another database of the account in the Machine's region, which no other Machine binds, and `--no-database` binds none; the database it leaves is kept. A Machine created with its own database keeps it.",
7223
7397
  "`--delete-after` applies at once and keeps the clock, which counts from the end of the Machine's last activation (or its creation), so a lower value can delete a Machine that has already slept that long; `--no-delete-after` turns it off. `tonbo machine show` prints when it will be deleted."
7224
7398
  ],
7225
- examples: ["tonbo machine update web --env MODE=staging --sleep-after 300"],
7399
+ examples: [
7400
+ "tonbo machine update web --env MODE=staging --sleep-after 300",
7401
+ "tonbo machine update agent --image ghcr.io/acme/agent:v2 --rebuild"
7402
+ ],
7226
7403
  arguments: {
7227
7404
  name: "the Machine's name",
7228
7405
  command: "the command that replaces the Machine's command at the next boot, after `--`"
@@ -7358,9 +7535,25 @@ var COMMAND_HELP = {
7358
7535
  arguments: NAME("Machine"),
7359
7536
  defaults: { "--timeout": String(DEFAULT_WAIT_SECONDS) }
7360
7537
  },
7538
+ "tonbo machine rebuild": {
7539
+ about: [
7540
+ "Moves a Machine whose Volume is its whole disk to an image: `--image`, or without it the Machine's own reference resolved again, so a newer build of its tag is taken. Rebuild keeps /home, /workspace and /root and deletes every other change on the disk. At a terminal it prints the warning of what it deletes and asks you to type the Machine's name first, and sends the ETag it read then.",
7541
+ "An asleep Machine answers at once: the Rebuild is recorded and the disk moves to the image at the next wake, or, when the disk was never used, the image changes with no Rebuild. An awake Machine with its disk attached is put to sleep first and nothing else changes; the CLI reads it until it is asleep, for up to `--timeout`, and sends the Rebuild again. A Machine whose Volume is mounted at a path, or that has none, is refused with `volume_not_whole_disk`: change its image with `tonbo machine update --image`."
7542
+ ],
7543
+ examples: [
7544
+ "tonbo machine rebuild agent --image ghcr.io/acme/agent:v2",
7545
+ "tonbo machine rebuild agent --yes"
7546
+ ],
7547
+ arguments: NAME("Machine"),
7548
+ defaults: {
7549
+ "--image": "the Machine's own image reference, resolved again",
7550
+ "--timeout": String(DEFAULT_WAIT_SECONDS)
7551
+ },
7552
+ json: "answer"
7553
+ },
7361
7554
  "tonbo machine delete": {
7362
7555
  about: [
7363
- "Deletes the Machine with its disk and logs; a Volume or database it binds stays. Its name is free for a new Machine at once. At a terminal it asks you to type the Machine's name first, and sends the ETag it read then.",
7556
+ "Deletes the Machine with its disk and logs; a database it binds stays. The Volume it holds is deleted with it when it was made for it, as the console makes a new Machine's disk, and kept otherwise; `--delete-volume` deletes any Volume it holds and `--keep-volume` keeps it. The command then says which. Its name is free for a new Machine at once. At a terminal it asks you to type the Machine's name first, and sends the ETag it read then.",
7364
7557
  "For one of your computers it forgets the computer: it leaves 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; run `yield logout` on it, or `tonbo api-key revoke`, first."
7365
7558
  ],
7366
7559
  examples: ["tonbo machine delete web --yes"],
@@ -9264,7 +9457,10 @@ function createProgram(makeDependencies = createDependencies) {
9264
9457
  machineSpecOptions(
9265
9458
  machine.command("update <name> [command...]").description(
9266
9459
  "change a Machine; [command...] after -- replaces its command at the next boot"
9267
- ).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").option("--no-volume", "remove the Machine's Volume at the next boot; the Volume is kept").option(
9460
+ ).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(
9461
+ "--rebuild",
9462
+ "with --image, move a Machine whose Volume is its whole disk to the image by Rebuild, which deletes changes outside /home, /workspace and /root"
9463
+ ).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").option("--no-volume", "remove the Machine's Volume at the next boot; the Volume is kept").option(
9268
9464
  "--database <name>",
9269
9465
  "bind the account's database <name> of the Machine's region instead; applies at the next wake"
9270
9466
  ).option("--no-database", "bind no database from the next wake; the database is kept").option("--no-delete-after", "never delete the Machine for being asleep"),
@@ -9390,7 +9586,19 @@ function createProgram(makeDependencies = createDependencies) {
9390
9586
  async (name, options) => machineWakeCommand(dependencies(), name, options)
9391
9587
  );
9392
9588
  operationOptions(
9393
- machine.command("delete <name>").description("delete a Machine with its disk and logs, or forget one of your computers").option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
9589
+ machine.command("rebuild <name>").description(
9590
+ "move a Machine whose Volume is its whole disk to an image; deletes changes outside /home, /workspace and /root"
9591
+ ).option("--image <reference>", "the image to move to, named as for create").option("--yes", "rebuild without asking; required when stdin is not a terminal").option(
9592
+ "--timeout <seconds>",
9593
+ "how long to wait for an awake Machine to sleep, in seconds",
9594
+ parseWhole
9595
+ ),
9596
+ "rebuildMachine"
9597
+ ).action(
9598
+ async (name, options) => machineRebuildCommand(dependencies(), name, options)
9599
+ );
9600
+ operationOptions(
9601
+ machine.command("delete <name>").description("delete a Machine with its disk and logs, or forget one of your computers").option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")).option("--keep-volume", "keep the Machine's Volume, even one made for it").option("--delete-volume", "delete the Machine's Volume with it, even one not made for it"),
9394
9602
  "deleteMachine"
9395
9603
  ).action(
9396
9604
  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.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "The Tonbo CLI. Compute, disks, databases and streams for AI agents: your agent picks what it needs, sets it up, and knows what it costs, from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {