@tonbo/cli 0.22.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
@@ -5777,12 +5834,13 @@ var MACHINE_ERROR_HINTS = {
5777
5834
  machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
5778
5835
  volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed.",
5779
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>.",
5780
- 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.",
5781
- 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.",
5782
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.",
5783
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`.",
5784
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.",
5785
- 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.",
5786
5844
  secret_env_conflict: "Use another secret key or remove the non-secret env variable with that name.",
5787
5845
  secret_limit: "Reduce the number or byte size of this Machine's secrets.",
5788
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."
@@ -5902,7 +5960,8 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
5902
5960
  capacity: "No host had room for its shape: Tonbo is adding a host, <minutes>, and the next request after that wakes it.",
5903
5961
  region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it.",
5904
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.",
5905
- 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)."
5906
5965
  };
5907
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.";
5908
5967
  function minutesUntil(retryAt, now = Date.now()) {
@@ -5975,7 +6034,7 @@ function imageFailureFields(machine) {
5975
6034
  ...next ? [["Next step", next]] : []
5976
6035
  ];
5977
6036
  }
5978
- function machineFields(machine, billing, etag = null, lastEnd = null) {
6037
+ function machineFields(machine, billing, etag = null, lastEnd = null, volumeName2 = null) {
5979
6038
  return fields([
5980
6039
  ["Name", machine.name],
5981
6040
  ["ID", machine.id],
@@ -5998,7 +6057,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
5998
6057
  ["SSH", `${machine.hostnames.ssh} (tonbo ssh ${machine.name})`],
5999
6058
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
6000
6059
  ["Stop grace", `${machine.stop_grace_seconds} s`],
6001
- ["Volume", volumeText(machine)],
6060
+ ["Volume", volumeText(machine, volumeName2)],
6002
6061
  ...deleteFields(machine),
6003
6062
  ["Database", machine.database ? `${machine.database.name} (${machine.database.url})` : "none"],
6004
6063
  ["Logs", machine.logs.url],
@@ -6043,13 +6102,28 @@ function deleteFields(machine) {
6043
6102
  ]
6044
6103
  ];
6045
6104
  }
6046
- function volumeText(machine) {
6105
+ function volumeText(machine, name = null) {
6047
6106
  const binding = machine.volumes?.[0];
6048
6107
  if (!binding) return "none";
6049
- 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})`;
6050
6111
  }
6051
- function machineText(machine, billing, etag = null, lastEnd = null) {
6052
- 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");
6053
6127
  }
6054
6128
  function keyStateText(machine) {
6055
6129
  const state = machine.byoc.key_state;
@@ -6074,8 +6148,8 @@ function deviceFields(machine) {
6074
6148
  ["Updated", machine.updated_at]
6075
6149
  ]);
6076
6150
  }
6077
- function anyMachineText(machine, billing, etag = null, lastEnd = null) {
6078
- 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);
6079
6153
  }
6080
6154
  function parsePorts(specs) {
6081
6155
  const accesses = machinePortAccess;
@@ -6444,11 +6518,15 @@ async function machineShowCommand(deps, name, options = {}) {
6444
6518
  ...processes.running.map((line) => `Background process: ${line}`),
6445
6519
  ...processes.last ? [`Last background process: ${processes.last}`] : []
6446
6520
  ] : [];
6521
+ const volume = options.json || machine.kind === "byoc" ? null : await volumeName(deps, key, machine);
6447
6522
  print(
6448
6523
  deps,
6449
6524
  options.json,
6450
6525
  machine,
6451
- [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")
6452
6530
  );
6453
6531
  });
6454
6532
  }
@@ -6532,6 +6610,8 @@ function appliesText(name, change) {
6532
6610
  }
6533
6611
  async function machineUpdateCommand(deps, name, command, options = {}) {
6534
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.");
6535
6615
  if (command.length && options.imageCommand)
6536
6616
  throw new Error("Give a command or --image-command, not both.");
6537
6617
  if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
@@ -6576,7 +6656,27 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
6576
6656
  if (options.discardSnapshot) change.discard_snapshot = true;
6577
6657
  if (Object.values(change).every((value) => value === void 0))
6578
6658
  throw new Error("Nothing to change: pass at least one option or a command.");
6579
- 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
+ }
6580
6680
  specWarnings(deps, machine.ports, Object.keys(setEnv));
6581
6681
  print(
6582
6682
  deps,
@@ -6584,12 +6684,80 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
6584
6684
  machine,
6585
6685
  [
6586
6686
  `Updated Machine ${machine.name} to revision ${machine.revision}.`,
6587
- ...appliesText(machine.name, change),
6687
+ ...rebuilt ? [rebuiltText(machine.name, rebuilt.rebuild)] : appliesText(machine.name, change),
6588
6688
  machineText(machine, await billingUrl(deps))
6589
6689
  ].join("\n")
6590
6690
  );
6591
6691
  });
6592
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
+ }
6593
6761
  function sleptText(machine) {
6594
6762
  if (machine.state === "creating")
6595
6763
  return `Machine ${machine.name} is still creating; nothing to put to sleep.`;
@@ -6672,43 +6840,61 @@ async function machineWakeCommand(deps, name, options = {}) {
6672
6840
  }
6673
6841
  async function machineDeleteCommand(deps, name, options = {}) {
6674
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;
6675
6846
  const key = await deps.auth.apiKey();
6676
6847
  const read = { etag: null };
6677
6848
  await confirm(
6678
6849
  deps,
6679
6850
  options.yes,
6680
6851
  `delete Machine ${name}`,
6681
- () => deleteQuestion(deps, key, name, read),
6852
+ () => deleteQuestion(deps, key, name, read, deleteVolume),
6682
6853
  name
6683
6854
  );
6684
- const { machine, kind } = await deps.api.deleteMachine(
6855
+ const { machine, kind, volume } = await deps.api.deleteMachine(
6685
6856
  key,
6686
6857
  name,
6687
- 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")
6688
6866
  );
6689
- print(deps, options.json, machine, deletedText(name, machine, kind));
6690
6867
  if (machine?.kind === "byoc" && machine.byoc.key_state === "active")
6691
6868
  deps.notice(
6692
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.`
6693
6870
  );
6694
6871
  });
6695
6872
  }
6696
- async function deleteQuestion(deps, key, name, read) {
6697
- let kind = null;
6698
- let ownDatabase = false;
6873
+ async function deleteQuestion(deps, key, name, read, deleteVolume) {
6874
+ let machine = null;
6699
6875
  try {
6700
6876
  const answer = await deps.api.getMachineWithEtag(key, name);
6701
- kind = answer.machine.kind;
6702
- ownDatabase = answer.machine.kind === "managed" && !!answer.machine.database?.name.startsWith("m.");
6877
+ machine = answer.machine;
6703
6878
  read.etag = answer.etag;
6704
6879
  } catch (error) {
6705
6880
  if (!(error instanceof HttpError && error.status === 404)) throw error;
6706
6881
  }
6707
- if (kind === "byoc")
6882
+ if (machine?.kind === "byoc")
6708
6883
  return `Forget your computer ${name}? It disappears from your Machines; its key is not revoked. Type its name to confirm:`;
6709
- if (kind === "managed")
6710
- return `Delete Machine ${name} with its disk${ownDatabase ? ", database" : ""} and logs? This cannot be undone. Type the Machine's name to confirm:`;
6711
- 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}\`.`;
6712
6898
  }
6713
6899
  function deletedText(name, machine, kind) {
6714
6900
  if (kind === "byoc")
@@ -6838,7 +7024,7 @@ var TOPICS = [
6838
7024
  id: "confirmations",
6839
7025
  title: "Destructive commands",
6840
7026
  text: [
6841
- "`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."
6842
7028
  ]
6843
7029
  }
6844
7030
  ];
@@ -7176,7 +7362,7 @@ var COMMAND_HELP = {
7176
7362
  },
7177
7363
  "tonbo machine show": {
7178
7364
  about: [
7179
- "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>`.",
7180
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."
7181
7367
  ],
7182
7368
  examples: ["tonbo machine show web"],
@@ -7207,10 +7393,13 @@ var COMMAND_HELP = {
7207
7393
  "tonbo machine update": {
7208
7394
  about: [
7209
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.",
7210
- "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.",
7211
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."
7212
7398
  ],
7213
- 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
+ ],
7214
7403
  arguments: {
7215
7404
  name: "the Machine's name",
7216
7405
  command: "the command that replaces the Machine's command at the next boot, after `--`"
@@ -7346,9 +7535,25 @@ var COMMAND_HELP = {
7346
7535
  arguments: NAME("Machine"),
7347
7536
  defaults: { "--timeout": String(DEFAULT_WAIT_SECONDS) }
7348
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
+ },
7349
7554
  "tonbo machine delete": {
7350
7555
  about: [
7351
- "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.",
7352
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."
7353
7558
  ],
7354
7559
  examples: ["tonbo machine delete web --yes"],
@@ -9252,7 +9457,10 @@ function createProgram(makeDependencies = createDependencies) {
9252
9457
  machineSpecOptions(
9253
9458
  machine.command("update <name> [command...]").description(
9254
9459
  "change a Machine; [command...] after -- replaces its command at the next boot"
9255
- ).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(
9256
9464
  "--database <name>",
9257
9465
  "bind the account's database <name> of the Machine's region instead; applies at the next wake"
9258
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"),
@@ -9378,7 +9586,19 @@ function createProgram(makeDependencies = createDependencies) {
9378
9586
  async (name, options) => machineWakeCommand(dependencies(), name, options)
9379
9587
  );
9380
9588
  operationOptions(
9381
- 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"),
9382
9602
  "deleteMachine"
9383
9603
  ).action(
9384
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.22.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": {