@tonbo/cli 0.17.2 → 0.17.3

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
@@ -30,6 +30,10 @@ Every command other than `login` and `logout` is named by one operation of the [
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
 
33
+ ## Updates
34
+
35
+ Every request to the Management API and to tonbo.dev carries the CLI's release in `Tonbo-CLI-Version`, and every answer names the latest release in `Tonbo-CLI-Latest` and the oldest supported one in `Tonbo-CLI-Minimum`, by the contract [`versions-v1.json`](../../contracts/cli/versions-v1.json): Latest is this package's `version` on main, so merging a release bump moves it with no other change, and Minimum is declared in the contract. When Latest is newer, the command ends with one line on stderr, at most once a day (`update-notice.json` beside the login), never with `--json`, without a terminal, under `CI` or with `TONBO_NO_UPDATE_NOTICE=1`. A release below Minimum is refused with 410 `cli_upgrade_required` on any request; the CLI prints the detail and exits 1. `tonbo update` runs the update of the package manager that installed it (npm, pnpm, bun or yarn), printing the command first, and only prints the command for npx or a path it cannot tell; `--check` only compares. [`src/update.ts`](src/update.ts) holds all of it.
36
+
33
37
  ## Related design
34
38
 
35
39
  - [ADR-0008: Tonbo CLI onboarding and credential boundaries](../../apps/user-center/docs/decisions/adr-0008-tonbo-cli-onboarding.md)
package/dist/bin/tonbo.js CHANGED
@@ -2,7 +2,6 @@
2
2
 
3
3
  // src/app.ts
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
- import { readFileSync as readFileSync2 } from "node:fs";
6
5
 
7
6
  // ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
8
7
  var agentNameContract = {
@@ -1773,6 +1772,102 @@ var loginContract = {
1773
1772
  ]
1774
1773
  }
1775
1774
  };
1775
+ var cliVersionsContract = {
1776
+ "version": 1,
1777
+ "description": "Which `tonbo` CLI (@tonbo/cli) releases Tonbo supports, and how a CLI learns of a newer one without a request of its own. User Center answers every request to the Management API host, and every request that carries `headers.client`, with `headers.latest` and `headers.minimum`. A request whose `headers.client` is a release below `minimum` is refused with `refusal.code` (contracts/management/problems-v1.json holds its status and title), its detail naming the update; a request without the header, or with one that is not a release, is never refused for its version. @tonbo/cli sends `headers.client` on every request, prints `notice.line` when `headers.latest` is newer than itself, and on the refusal prints its detail and exits 1. @tonbo/yield does not send the header.",
1778
+ "package": "@tonbo/cli",
1779
+ "latest": {
1780
+ "source": "cli/tonbo/package.json",
1781
+ "field": "version",
1782
+ "description": "The release main carries when User Center is deployed. Merging a version change to main is what publishes it (tonbo-cli-release.yml); the same push redeploys User Center, whose deploy waits until npm serves that version before it ships, so `latest` is always installable and follows each release with no bump of its own."
1783
+ },
1784
+ "minimum": "0.12.0",
1785
+ "minimum_reason": "0.12.0 is the oldest release that can sign in: the login of 0.11 and earlier was retired when 0.12 shipped (contracts/iam/access-token-v1.json `cli_session.login.retired`). Raise it only when a change of the API leaves older releases unable to work, and in the same change say so in the CLI's changelog.",
1786
+ "headers": {
1787
+ "client": "Tonbo-CLI-Version",
1788
+ "latest": "Tonbo-CLI-Latest",
1789
+ "minimum": "Tonbo-CLI-Minimum"
1790
+ },
1791
+ "release_pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(-[0-9A-Za-z.-]+)?$",
1792
+ "release_order": "Semantic Versioning 2.0.0 precedence: major, minor and patch compare as numbers, and a release with a pre-release suffix is older than the same release without one; pre-release suffixes compare as text. A value that does not match `release_pattern` is not a release and orders with nothing. `order_examples` are [a, b, the sign of a compared with b, or null when either is not a release]; every implementation runs them.",
1793
+ "order_examples": [
1794
+ [
1795
+ "0.17.2",
1796
+ "0.17.3",
1797
+ -1
1798
+ ],
1799
+ [
1800
+ "0.17.3",
1801
+ "0.17.3",
1802
+ 0
1803
+ ],
1804
+ [
1805
+ "0.18.0",
1806
+ "0.17.9",
1807
+ 1
1808
+ ],
1809
+ [
1810
+ "0.9.0",
1811
+ "0.12.0",
1812
+ -1
1813
+ ],
1814
+ [
1815
+ "1.0.0",
1816
+ "0.99.99",
1817
+ 1
1818
+ ],
1819
+ [
1820
+ "0.18.0-beta.1",
1821
+ "0.18.0",
1822
+ -1
1823
+ ],
1824
+ [
1825
+ "0.18.0-beta.2",
1826
+ "0.18.0-beta.1",
1827
+ 1
1828
+ ],
1829
+ [
1830
+ "0.17",
1831
+ "0.17.0",
1832
+ null
1833
+ ],
1834
+ [
1835
+ "v0.17.2",
1836
+ "0.17.2",
1837
+ null
1838
+ ],
1839
+ [
1840
+ "0.17.2 ",
1841
+ "0.17.2",
1842
+ null
1843
+ ],
1844
+ [
1845
+ "",
1846
+ "0.12.0",
1847
+ null
1848
+ ]
1849
+ ],
1850
+ "refusal": {
1851
+ "code": "cli_upgrade_required",
1852
+ "registry": "contracts/management/problems-v1.json"
1853
+ },
1854
+ "update_command": "npm install --global @tonbo/cli@latest",
1855
+ "notice": {
1856
+ "line": "tonbo {latest} is available (you have {current}): run tonbo update",
1857
+ "stream": "stderr",
1858
+ "interval_hours": 24,
1859
+ "state": {
1860
+ "description": "When the CLI last printed the notice: `${XDG_CONFIG_HOME}/tonbo/update-notice.json`, else `${HOME}/.config/tonbo/update-notice.json`, beside the login. A state that cannot be read counts as never; one that cannot be written never fails the command.",
1861
+ "file": "update-notice.json"
1862
+ },
1863
+ "silent_when": [
1864
+ "the command line has --json",
1865
+ "neither stdout nor stderr is a terminal",
1866
+ "CI is set to anything but empty, 0 or false",
1867
+ "TONBO_NO_UPDATE_NOTICE is set to anything but empty or 0"
1868
+ ]
1869
+ }
1870
+ };
1776
1871
  var cliSessionContract = {
1777
1872
  "prefix": "tbs_",
1778
1873
  "grant": "cli_session",
@@ -2036,7 +2131,7 @@ var ManagementApi = class {
2036
2131
  }
2037
2132
  async requestWith(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }, seen) {
2038
2133
  const operation = managementOperation(operationId);
2039
- const path5 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
2134
+ const path6 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
2040
2135
  const value = params[name];
2041
2136
  if (value === void 0)
2042
2137
  throw new Error(`${operationId} needs the path parameter ${name}.`);
@@ -2060,7 +2155,7 @@ var ManagementApi = class {
2060
2155
  if (ifMatch)
2061
2156
  headers["if-match"] = ifMatch;
2062
2157
  const queryString = search.toString();
2063
- const url = `${this.apiOrigin}${managementApiBasePath}${path5}${queryString ? `?${queryString}` : ""}`;
2158
+ const url = `${this.apiOrigin}${managementApiBasePath}${path6}${queryString ? `?${queryString}` : ""}`;
2064
2159
  try {
2065
2160
  const fetcher = seen ? async (input, init) => {
2066
2161
  const response = await this.fetcher(input, init);
@@ -3867,7 +3962,7 @@ var CellApi = class {
3867
3962
  }
3868
3963
  url(operationId, region, request) {
3869
3964
  const operation = managementOperations[operationId];
3870
- const path5 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
3965
+ const path6 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
3871
3966
  const value = request.params[name];
3872
3967
  if (value === void 0)
3873
3968
  throw new Error(`${operationId} needs the path parameter ${name}.`);
@@ -3878,7 +3973,7 @@ var CellApi = class {
3878
3973
  if (value !== void 0)
3879
3974
  search.set(name, value);
3880
3975
  const query = search.toString();
3881
- return `${cellOrigin(region)}${managementApiBasePath}${path5}${query ? `?${query}` : ""}`;
3976
+ return `${cellOrigin(region)}${managementApiBasePath}${path6}${query ? `?${query}` : ""}`;
3882
3977
  }
3883
3978
  async init(operationId, key, request) {
3884
3979
  const headers = {
@@ -5571,17 +5666,23 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
5571
5666
  standing: "The account's Streams standing is not solvent: settle the account at <billing>, and the next request wakes it.",
5572
5667
  meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
5573
5668
  crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
5574
- capacity: "No host had room for its shape; the next request tries again.",
5669
+ capacity: "No host had room for its shape: Tonbo is adding a host, <minutes>, and the next request after that wakes it.",
5575
5670
  region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it.",
5576
5671
  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.",
5577
5672
  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."
5578
5673
  };
5579
5674
  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.";
5675
+ function minutesUntil(retryAt, now = Date.now()) {
5676
+ const at = retryAt ? Date.parse(retryAt) : Number.NaN;
5677
+ if (!Number.isFinite(at) || at <= now) return "which takes a few minutes";
5678
+ const minutes = Math.ceil((at - now) / 6e4);
5679
+ return `about ${minutes} minute${minutes === 1 ? "" : "s"}`;
5680
+ }
5580
5681
  function conditionText(name, condition, billing) {
5581
5682
  switch (condition.type) {
5582
5683
  case "wake_blocked": {
5583
5684
  const retry = condition.retry_at ? `, retry after ${condition.retry_at}` : "";
5584
- const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name).replaceAll("<billing>", billing);
5685
+ const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name).replaceAll("<billing>", billing).replaceAll("<minutes>", minutesUntil(condition.retry_at));
5585
5686
  return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}${retry}.${hint ? ` ${hint}` : ""}`;
5586
5687
  }
5587
5688
  case "creation_delayed":
@@ -6396,7 +6497,15 @@ var ENVIRONMENT = [
6396
6497
  },
6397
6498
  {
6398
6499
  names: ["XDG_CONFIG_HOME"],
6399
- description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/login.json` when it is set and not empty, else `$HOME/.config/tonbo/login.json`. A login an earlier release saved, `credentials.json` beside it, is read only when there is no `login.json`."
6500
+ description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/login.json` when it is set and not empty, else `$HOME/.config/tonbo/login.json`. A login an earlier release saved, `credentials.json` beside it, is read only when there is no `login.json`. `update-notice.json` beside it records when the update notice last printed."
6501
+ },
6502
+ {
6503
+ names: ["TONBO_NO_UPDATE_NOTICE"],
6504
+ description: "Set to `1` (anything but empty or `0`), the CLI never prints that a newer release is available."
6505
+ },
6506
+ {
6507
+ names: ["CI"],
6508
+ description: "Set to anything but empty, `0` or `false`, as CI services set it, the CLI never prints that a newer release is available."
6400
6509
  },
6401
6510
  {
6402
6511
  names: ["XDG_STATE_HOME"],
@@ -6451,6 +6560,14 @@ var TOPICS = [
6451
6560
  "`api-key create` takes no idempotency key, because a key's secret is never stored and a replay could not return it; a live key's name is unique, so a blind retry is refused with 409 rather than creating a second key. Every other command reads, or sets a resource to the state it names, so running it again does no more than the first run did; a repeated delete or revoke may be refused because the first one already removed it."
6452
6561
  ]
6453
6562
  },
6563
+ {
6564
+ id: "updates",
6565
+ title: "Updates",
6566
+ text: [
6567
+ "The CLI names its release in a `Tonbo-CLI-Version` header on every request to the Management API and to tonbo.dev, and every answer of the Management API names the latest release in `Tonbo-CLI-Latest` and the oldest one Tonbo supports in `Tonbo-CLI-Minimum`, so the CLI learns of a new release with no request of its own; `curl -sI https://api.tonbo.dev/v1/openapi.json` shows both headers. When the latest is newer, the command ends with `tonbo <latest> is available (you have <current>): run tonbo update` on stderr, at most once a day. It never prints with `--json`, when neither stdout nor stderr is a terminal, under `CI=true`, or with `TONBO_NO_UPDATE_NOTICE=1`. An agent that runs the CLI in a terminal sees it too, and should run `tonbo update` before it goes on.",
6568
+ "`tonbo update` updates the CLI with the package manager that installed it, printing the command before it runs it; nothing is ever updated in the background. A release older than `Tonbo-CLI-Minimum` is refused on every request with 410 `cli_upgrade_required`: the CLI prints the refusal, which names the update, and exits 1. `tonbo update` itself still works then."
6569
+ ]
6570
+ },
6454
6571
  {
6455
6572
  id: "confirmations",
6456
6573
  title: "Destructive commands",
@@ -6489,6 +6606,13 @@ var COMMAND_HELP = {
6489
6606
  ],
6490
6607
  examples: ["tonbo logout"]
6491
6608
  },
6609
+ "tonbo update": {
6610
+ about: [
6611
+ "Reads the latest release from the `Tonbo-CLI-Latest` header of the Management API's answer, then updates the CLI with the package manager that installed it: `npm install --global @tonbo/cli@latest`, `pnpm add --global @tonbo/cli@latest`, `bun add --global @tonbo/cli@latest` or `yarn global add @tonbo/cli@latest`. It prints the command first, then runs it in this terminal, and exits with 1 if it fails. When this release is already the latest, it says so and runs nothing.",
6612
+ "A CLI run through npx, `pnpm dlx` or bunx runs the release you name each time, so it prints `npx @tonbo/cli@latest` and runs nothing. When it cannot tell how the CLI was installed, as for a project's own dependency or a copy, it prints the command to run and exits 1. `--check` only prints this release and the latest one."
6613
+ ],
6614
+ examples: ["tonbo update", "tonbo update --check", "tonbo update --check --json"]
6615
+ },
6492
6616
  "tonbo account show": {
6493
6617
  about: [
6494
6618
  "Prints the account commands act in: its id, name and kind, your member subject and role in it, and its limits."
@@ -7102,19 +7226,19 @@ function optionDescription(option) {
7102
7226
  function applyHelp(program) {
7103
7227
  const named = /* @__PURE__ */ new Set();
7104
7228
  const walk = (command, words2) => {
7105
- const path5 = words2.join(" ");
7229
+ const path6 = words2.join(" ");
7106
7230
  command.configureHelp({ ...command.configureHelp(), optionDescription });
7107
- const help = COMMAND_HELP[path5];
7231
+ const help = COMMAND_HELP[path6];
7108
7232
  if (help) {
7109
- named.add(path5);
7233
+ named.add(path6);
7110
7234
  for (const [name, text] of Object.entries(help.arguments ?? {})) {
7111
7235
  const argument = command.registeredArguments.find((entry) => entry.name() === name);
7112
- if (!argument) throw new Error(`${path5} has no argument ${name}`);
7236
+ if (!argument) throw new Error(`${path6} has no argument ${name}`);
7113
7237
  argument.description = text;
7114
7238
  }
7115
7239
  for (const [flag, text] of Object.entries(help.defaults ?? {})) {
7116
7240
  const option = command.options.find((entry) => entry.long === flag);
7117
- if (!option) throw new Error(`${path5} has no option ${flag}`);
7241
+ if (!option) throw new Error(`${path6} has no option ${flag}`);
7118
7242
  option.defaultValueDescription = text;
7119
7243
  }
7120
7244
  command.addHelpText("after", commandHelpText(help));
@@ -7122,8 +7246,8 @@ function applyHelp(program) {
7122
7246
  for (const child of command.commands) walk(child, [...words2, child.name()]);
7123
7247
  };
7124
7248
  walk(program, [program.name()]);
7125
- for (const path5 of Object.keys(COMMAND_HELP))
7126
- if (!named.has(path5)) throw new Error(`The help of ${path5} names no command`);
7249
+ for (const path6 of Object.keys(COMMAND_HELP))
7250
+ if (!named.has(path6)) throw new Error(`The help of ${path6} names no command`);
7127
7251
  program.addHelpText("after", programHelpText());
7128
7252
  return program;
7129
7253
  }
@@ -7498,13 +7622,13 @@ async function machineProcessSummary(cell, key, region, name) {
7498
7622
  return null;
7499
7623
  }
7500
7624
  }
7501
- async function machineFileReadCommand(deps, name, path5, options = {}) {
7625
+ async function machineFileReadCommand(deps, name, path6, options = {}) {
7502
7626
  try {
7503
7627
  const key = await deps.auth.apiKey();
7504
7628
  const region = await regionOf(deps, key, name);
7505
7629
  const response = await cellApi(deps).fetch("readMachineFile", key, region, {
7506
7630
  params: { name },
7507
- query: { path: path5 },
7631
+ query: { path: path6 },
7508
7632
  headers: { range: options.range, "tonbo-expect-activation": options.expectActivation }
7509
7633
  });
7510
7634
  const bytes2 = new Uint8Array(await response.arrayBuffer());
@@ -7514,14 +7638,14 @@ async function machineFileReadCommand(deps, name, path5, options = {}) {
7514
7638
  throw explainMachineError(error);
7515
7639
  }
7516
7640
  }
7517
- async function machineFileWriteCommand(deps, name, path5, options = {}) {
7641
+ async function machineFileWriteCommand(deps, name, path6, options = {}) {
7518
7642
  try {
7519
7643
  const bytes2 = options.from ? new Uint8Array(await readFile4(options.from)) : await readAllStdin(deps);
7520
7644
  const key = await deps.auth.apiKey();
7521
7645
  const region = await regionOf(deps, key, name);
7522
7646
  const written = await cellApi(deps).json("writeMachineFile", key, region, {
7523
7647
  params: { name },
7524
- query: { path: path5 },
7648
+ query: { path: path6 },
7525
7649
  headers: {
7526
7650
  "content-type": "application/octet-stream",
7527
7651
  "if-match": options.ifMatch,
@@ -7543,7 +7667,7 @@ async function machineFileWriteCommand(deps, name, path5, options = {}) {
7543
7667
  throw explainMachineError(error);
7544
7668
  }
7545
7669
  }
7546
- async function machineFileStatCommand(deps, name, path5, options = {}) {
7670
+ async function machineFileStatCommand(deps, name, path6, options = {}) {
7547
7671
  try {
7548
7672
  const key = await deps.auth.apiKey();
7549
7673
  const region = await regionOf(deps, key, name);
@@ -7553,7 +7677,7 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
7553
7677
  region,
7554
7678
  {
7555
7679
  params: { name },
7556
- query: { path: path5 },
7680
+ query: { path: path6 },
7557
7681
  headers: { "tonbo-expect-activation": options.expectActivation }
7558
7682
  }
7559
7683
  );
@@ -7572,13 +7696,13 @@ async function machineFileStatCommand(deps, name, path5, options = {}) {
7572
7696
  throw explainMachineError(error);
7573
7697
  }
7574
7698
  }
7575
- async function machineFileListCommand(deps, name, path5, options = {}) {
7699
+ async function machineFileListCommand(deps, name, path6, options = {}) {
7576
7700
  try {
7577
7701
  const key = await deps.auth.apiKey();
7578
7702
  const region = await regionOf(deps, key, name);
7579
7703
  const listing = await cellApi(deps).json("listMachineFiles", key, region, {
7580
7704
  params: { name },
7581
- query: { path: path5 },
7705
+ query: { path: path6 },
7582
7706
  headers: { "tonbo-expect-activation": options.expectActivation }
7583
7707
  });
7584
7708
  noticeWake(deps, listing.machine, name);
@@ -7602,19 +7726,19 @@ async function machineFileListCommand(deps, name, path5, options = {}) {
7602
7726
  throw explainMachineError(error);
7603
7727
  }
7604
7728
  }
7605
- async function machineFileDeleteCommand(deps, name, path5, options = {}) {
7729
+ async function machineFileDeleteCommand(deps, name, path6, options = {}) {
7606
7730
  try {
7607
7731
  const key = await deps.auth.apiKey();
7608
7732
  const region = await regionOf(deps, key, name);
7609
7733
  await cellApi(deps).fetch("deleteMachineFile", key, region, {
7610
7734
  params: { name },
7611
- query: { path: path5, recursive: options.recursive ? "true" : void 0 },
7735
+ query: { path: path6, recursive: options.recursive ? "true" : void 0 },
7612
7736
  headers: {
7613
7737
  "tonbo-expect-activation": options.expectActivation,
7614
7738
  "idempotency-key": randomUUID8()
7615
7739
  }
7616
7740
  });
7617
- print(deps, options.json, { path: path5 }, `Deleted ${path5}.`);
7741
+ print(deps, options.json, { path: path6 }, `Deleted ${path6}.`);
7618
7742
  } catch (error) {
7619
7743
  throw explainMachineError(error);
7620
7744
  }
@@ -7730,8 +7854,8 @@ var LEGACY_BEGIN = "# >>> tonbo project ssh >>>";
7730
7854
  var LEGACY_END = "# <<< tonbo project ssh <<<";
7731
7855
  var PROXY_COMMAND = /^\s*proxycommand(\s|=)/i;
7732
7856
  var SSH_PROXY = /(^|[\s'"/])ssh-proxy(\s|['"]|$)/;
7733
- function shown(path5, home) {
7734
- return path5.startsWith(home + sep) ? `~${path5.slice(home.length)}` : path5;
7857
+ function shown(path6, home) {
7858
+ return path6.startsWith(home + sep) ? `~${path6.slice(home.length)}` : path6;
7735
7859
  }
7736
7860
  function words(text) {
7737
7861
  const found = [];
@@ -7747,15 +7871,15 @@ async function expand(pattern) {
7747
7871
  let paths = [sep];
7748
7872
  for (const part of parts) {
7749
7873
  if (!/[*?]/.test(part)) {
7750
- paths = paths.map((path5) => join(path5, part));
7874
+ paths = paths.map((path6) => join(path6, part));
7751
7875
  continue;
7752
7876
  }
7753
7877
  const matcher = wildcard(part);
7754
7878
  const next = [];
7755
- for (const path5 of paths) {
7756
- const names = await readdir(path5).catch(() => []);
7879
+ for (const path6 of paths) {
7880
+ const names = await readdir(path6).catch(() => []);
7757
7881
  for (const name of names.sort())
7758
- if (matcher.test(name) && !name.startsWith(".")) next.push(join(path5, name));
7882
+ if (matcher.test(name) && !name.startsWith(".")) next.push(join(path6, name));
7759
7883
  }
7760
7884
  paths = next;
7761
7885
  }
@@ -7765,18 +7889,18 @@ async function scan(home) {
7765
7889
  const sshDir = join(home, ".ssh");
7766
7890
  const files = [];
7767
7891
  const seen = /* @__PURE__ */ new Set();
7768
- const visit = async (path5, depth) => {
7769
- if (depth > 16 || seen.has(path5) || !path5.startsWith(sshDir + sep)) return;
7770
- seen.add(path5);
7892
+ const visit = async (path6, depth) => {
7893
+ if (depth > 16 || seen.has(path6) || !path6.startsWith(sshDir + sep)) return;
7894
+ seen.add(path6);
7771
7895
  let text;
7772
7896
  try {
7773
- if (!(await stat2(path5)).isFile()) return;
7774
- text = await readFile5(path5, "utf8");
7897
+ if (!(await stat2(path6)).isFile()) return;
7898
+ text = await readFile5(path6, "utf8");
7775
7899
  } catch {
7776
7900
  return;
7777
7901
  }
7778
7902
  const lines = text.split("\n");
7779
- const file = { path: path5, lines, blocks: [], handEdited: [] };
7903
+ const file = { path: path6, lines, blocks: [], handEdited: [] };
7780
7904
  const includes = [];
7781
7905
  for (let index = 0; index < lines.length; index++) {
7782
7906
  if (lines[index].trim() === LEGACY_BEGIN) {
@@ -7915,10 +8039,10 @@ async function ensureKey(directory) {
7915
8039
  }
7916
8040
  return key;
7917
8041
  }
7918
- async function writeAtomic(path5, data) {
7919
- const temporary = `${path5}.${process.pid}.tmp`;
8042
+ async function writeAtomic(path6, data) {
8043
+ const temporary = `${path6}.${process.pid}.tmp`;
7920
8044
  await writeFile3(temporary, data, { mode: 384 });
7921
- await rename5(temporary, path5);
8045
+ await rename5(temporary, path6);
7922
8046
  }
7923
8047
  function certificatePath(directory, user) {
7924
8048
  return join2(directory, `${user}-cert.pub`);
@@ -8122,33 +8246,255 @@ async function sshCertificateIssueCommand(deps, options) {
8122
8246
  print(deps, options.json, data, data.certificate);
8123
8247
  }
8124
8248
 
8249
+ // src/update.ts
8250
+ import { execFile as execFile3, spawn as spawn2 } from "node:child_process";
8251
+ import { readFileSync as readFileSync2 } from "node:fs";
8252
+ import { mkdir as mkdir5, readFile as readFile7, realpath as realpath2, rename as rename6, writeFile as writeFile4 } from "node:fs/promises";
8253
+ import path5 from "node:path";
8254
+ import { fileURLToPath } from "node:url";
8255
+ import { promisify as promisify3 } from "node:util";
8256
+ var contract = cliVersionsContract;
8257
+ var CLI_VERSION = JSON.parse(readFileSync2(new URL("../../package.json", import.meta.url), "utf8")).version;
8258
+ var RELEASE = new RegExp(contract.release_pattern);
8259
+ function compareReleases(a, b) {
8260
+ const left = RELEASE.exec(a);
8261
+ const right = RELEASE.exec(b);
8262
+ if (!left || !right) return null;
8263
+ for (let at = 1; at <= 3; at++) {
8264
+ const difference = Number(left[at]) - Number(right[at]);
8265
+ if (difference !== 0) return Math.sign(difference);
8266
+ }
8267
+ const [leftPre, rightPre] = [left[4], right[4]];
8268
+ if (leftPre === rightPre) return 0;
8269
+ if (leftPre === void 0) return 1;
8270
+ if (rightPre === void 0) return -1;
8271
+ return leftPre < rightPre ? -1 : 1;
8272
+ }
8273
+ function latestOf(headers) {
8274
+ const latest = headers.get(contract.headers.latest)?.trim() ?? "";
8275
+ return RELEASE.test(latest) ? latest : null;
8276
+ }
8277
+ function noticeLine(current, latest) {
8278
+ if (latest === null || (compareReleases(current, latest) ?? 0) >= 0) return null;
8279
+ return contract.notice.line.replace("{latest}", latest).replace("{current}", current);
8280
+ }
8281
+ var ReleaseWatch = class {
8282
+ constructor(current = CLI_VERSION, refuse = (message) => {
8283
+ process.stderr.write(`${message}
8284
+ `);
8285
+ process.exit(1);
8286
+ }) {
8287
+ this.current = current;
8288
+ this.refuse = refuse;
8289
+ }
8290
+ latest = null;
8291
+ fetch(fetcher) {
8292
+ return async (input, init) => {
8293
+ const headers = new Headers(
8294
+ init?.headers ?? (input instanceof Request ? input.headers : void 0)
8295
+ );
8296
+ headers.set(contract.headers.client, this.current);
8297
+ const response = await fetcher(input, { ...init, headers });
8298
+ this.latest = latestOf(response.headers) ?? this.latest;
8299
+ if (response.status === 410) {
8300
+ const body = await response.clone().json().catch(() => null);
8301
+ if (body?.code === contract.refusal.code) {
8302
+ const reason2 = [body.detail, body.error].find(
8303
+ (value) => typeof value === "string"
8304
+ );
8305
+ this.refuse(
8306
+ reason2 ?? `This tonbo (${this.current}) is no longer supported. Run \`tonbo update\` (or \`${contract.update_command}\`).`
8307
+ );
8308
+ }
8309
+ }
8310
+ return response;
8311
+ };
8312
+ }
8313
+ };
8314
+ var set = (value) => !!value && value !== "0";
8315
+ function noticeAllowed({ env, argv, stdoutIsTTY, stderrIsTTY }) {
8316
+ if (set(env.TONBO_NO_UPDATE_NOTICE)) return false;
8317
+ if (set(env.CI) && env.CI?.toLowerCase() !== "false") return false;
8318
+ if (argv.includes("--json")) return false;
8319
+ return stdoutIsTTY || stderrIsTTY;
8320
+ }
8321
+ function noticeStatePath(env = process.env) {
8322
+ return path5.join(path5.dirname(defaultLoginPath(env)), contract.notice.state.file);
8323
+ }
8324
+ var INTERVAL_MS = contract.notice.interval_hours * 60 * 60 * 1e3;
8325
+ function noticeDue(state, now) {
8326
+ const at = state && typeof state === "object" ? Date.parse(String(state.notified_at)) : Number.NaN;
8327
+ return !Number.isFinite(at) || at > now || now - at >= INTERVAL_MS;
8328
+ }
8329
+ async function showUpdateNotice(watch, {
8330
+ context,
8331
+ statePath,
8332
+ now = Date.now(),
8333
+ write = (line) => process.stderr.write(`${line}
8334
+ `)
8335
+ }) {
8336
+ const line = noticeLine(watch.current, watch.latest);
8337
+ if (line === null || !noticeAllowed(context)) return;
8338
+ const state = await readFile7(statePath, "utf8").then((text) => JSON.parse(text)).catch(() => null);
8339
+ if (!noticeDue(state, now)) return;
8340
+ write(line);
8341
+ try {
8342
+ await mkdir5(path5.dirname(statePath), { recursive: true, mode: 448 });
8343
+ const temporary = `${statePath}.${process.pid}.tmp`;
8344
+ await writeFile4(
8345
+ temporary,
8346
+ `${JSON.stringify({ version: 1, notified_at: new Date(now).toISOString() })}
8347
+ `,
8348
+ { mode: 384 }
8349
+ );
8350
+ await rename6(temporary, statePath);
8351
+ } catch {
8352
+ }
8353
+ }
8354
+ function installMethod(location) {
8355
+ const at = location.replaceAll("\\", "/").toLowerCase();
8356
+ if (at.includes("/_npx/") || at.includes("/bunx-") || at.includes("/pnpm/dlx/")) return "npx";
8357
+ if (at.includes("/pnpm/global/") || at.includes("/global/") && at.includes("/.pnpm/"))
8358
+ return "pnpm";
8359
+ if (at.includes("/install/global/node_modules/")) return "bun";
8360
+ if (at.includes("/yarn/global/") || at.includes("/yarn/data/global/")) return "yarn";
8361
+ if (/\/(lib|npm|nodejs)\/node_modules\/@tonbo\/cli\//.test(at)) return "npm";
8362
+ return "standalone";
8363
+ }
8364
+ var PACKAGE = `${contract.package}@latest`;
8365
+ var UPDATE_COMMANDS = {
8366
+ npm: ["npm", "install", "--global", PACKAGE],
8367
+ pnpm: ["pnpm", "add", "--global", PACKAGE],
8368
+ bun: ["bun", "add", "--global", PACKAGE],
8369
+ yarn: ["yarn", "global", "add", PACKAGE]
8370
+ };
8371
+ function updateCommandLine(method) {
8372
+ if (method === "npx") return `npx ${PACKAGE}`;
8373
+ if (method === "standalone") return contract.update_command;
8374
+ return UPDATE_COMMANDS[method].join(" ");
8375
+ }
8376
+ async function readGlobalRoot(manager) {
8377
+ try {
8378
+ const { stdout } = await promisify3(execFile3)(manager, ["root", "-g"], {
8379
+ shell: process.platform === "win32",
8380
+ timeout: 15e3
8381
+ });
8382
+ return await realpath2(stdout.trim());
8383
+ } catch {
8384
+ return null;
8385
+ }
8386
+ }
8387
+ function isInside(location, directory) {
8388
+ const normal = (value) => value.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
8389
+ return normal(location).startsWith(`${normal(directory)}/`);
8390
+ }
8391
+ async function confirmedInstallMethod(location, globalRoot) {
8392
+ const method = installMethod(location);
8393
+ if (method !== "npm" && method !== "pnpm") return method;
8394
+ const root = await globalRoot(method);
8395
+ if (root === null) return "standalone";
8396
+ return isInside(location, method === "pnpm" ? path5.dirname(root) : root) ? method : "standalone";
8397
+ }
8398
+ async function readLatest(origin, fetcher = fetch) {
8399
+ try {
8400
+ const response = await fetcher(`${origin}/v1/openapi.json`, {
8401
+ method: "HEAD",
8402
+ signal: AbortSignal.timeout(1e4)
8403
+ });
8404
+ return latestOf(response.headers);
8405
+ } catch {
8406
+ return null;
8407
+ }
8408
+ }
8409
+ function runCommand(command) {
8410
+ return new Promise((resolve2, reject) => {
8411
+ const child = spawn2(command[0], command.slice(1), {
8412
+ stdio: "inherit",
8413
+ // npm, pnpm and yarn are .cmd scripts on Windows, which only a shell runs.
8414
+ shell: process.platform === "win32"
8415
+ });
8416
+ child.on("error", reject);
8417
+ child.on("exit", (code, signal) => resolve2(code ?? (signal ? 1 : 0)));
8418
+ });
8419
+ }
8420
+ var SCRIPT_LOCATION = fileURLToPath(import.meta.url);
8421
+ async function updateCommand(deps, options) {
8422
+ if (options.json && !options.check) throw new Error("--json goes with --check.");
8423
+ const latest = await deps.latest();
8424
+ const method = await confirmedInstallMethod(deps.location, deps.globalRoot);
8425
+ const command = updateCommandLine(method);
8426
+ const order = latest === null ? null : compareReleases(deps.current, latest);
8427
+ const available = order !== null && order < 0;
8428
+ if (options.check) {
8429
+ if (options.json) {
8430
+ deps.output(
8431
+ JSON.stringify({
8432
+ current: deps.current,
8433
+ latest,
8434
+ update_available: available,
8435
+ install: method,
8436
+ command
8437
+ })
8438
+ );
8439
+ } else if (latest === null) {
8440
+ deps.output(`tonbo ${deps.current}; the latest release could not be read from Tonbo.`);
8441
+ } else if (available) {
8442
+ deps.output(`tonbo ${deps.current}; ${latest} is available: run tonbo update`);
8443
+ } else {
8444
+ deps.output(`tonbo ${deps.current} is the latest release.`);
8445
+ }
8446
+ return;
8447
+ }
8448
+ if (latest !== null && !available) {
8449
+ deps.output(`tonbo ${deps.current} is the latest release.`);
8450
+ return;
8451
+ }
8452
+ if (method === "npx") {
8453
+ deps.notice(
8454
+ `This tonbo runs through a package runner, which runs the release you name each time: run \`${command}\`.`
8455
+ );
8456
+ return;
8457
+ }
8458
+ if (method === "standalone") {
8459
+ deps.notice(
8460
+ `Could not tell how the tonbo at ${deps.location} was installed, so nothing was run. Update it with \`${command}\`, or the way you installed it.`
8461
+ );
8462
+ process.exitCode = 1;
8463
+ return;
8464
+ }
8465
+ deps.notice(`Running: ${command}`);
8466
+ const status = await deps.run(UPDATE_COMMANDS[method]);
8467
+ if (status !== 0)
8468
+ throw new Error(`${command} exited with status ${status}; tonbo was not updated.`);
8469
+ deps.output(`Updated tonbo${latest ? ` to ${latest}` : ""}: run tonbo --version to check.`);
8470
+ }
8471
+
8125
8472
  // src/app.ts
8126
- var packageVersion = JSON.parse(
8127
- readFileSync2(new URL("../../package.json", import.meta.url), "utf8")
8128
- );
8473
+ var releaseWatch = new ReleaseWatch();
8129
8474
  function createDependencies(selection = {}) {
8130
8475
  const api = apiOrigin();
8476
+ const fetcher = releaseWatch.fetch(fetch);
8131
8477
  const account = selection.account?.trim() || process.env.TONBO_ACCOUNT?.trim() || void 0;
8132
8478
  const credentials = new LoginStore({
8133
- fetcher: fetch,
8479
+ fetcher,
8134
8480
  apiOrigin: api,
8135
8481
  loginOrigin: loginOrigin(api),
8136
8482
  account
8137
8483
  });
8138
8484
  const notice = (text) => console.error(text);
8139
8485
  return {
8140
- api: new ManagementApi(noticingBudget(fetch, notice), api),
8486
+ api: new ManagementApi(noticingBudget(fetcher, notice), api),
8141
8487
  ask: terminalAsk,
8142
8488
  auth: new AuthClient(
8143
8489
  credentials,
8144
- fetch,
8490
+ fetcher,
8145
8491
  loginOrigin(api),
8146
8492
  void 0,
8147
8493
  void 0,
8148
8494
  void 0,
8149
8495
  void 0,
8150
8496
  process.env,
8151
- { client: "tonbo", clientVersion: packageVersion.version }
8497
+ { client: "tonbo", clientVersion: CLI_VERSION }
8152
8498
  ),
8153
8499
  credentials,
8154
8500
  interactive: () => process.stdin.isTTY === true && process.stderr.isTTY === true,
@@ -8233,7 +8579,7 @@ function parseUsageSort(value) {
8233
8579
  }
8234
8580
  }
8235
8581
  function createProgram(makeDependencies = createDependencies) {
8236
- const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version).option(
8582
+ const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(CLI_VERSION).option(
8237
8583
  "--account <account>",
8238
8584
  "the account to act in, by name or id: any account you belong to; else TONBO_ACCOUNT, else the default `tonbo account use` set, else your personal account"
8239
8585
  );
@@ -8247,6 +8593,20 @@ function createProgram(makeDependencies = createDependencies) {
8247
8593
  const account2 = program.opts().account;
8248
8594
  await loginCommand(dependencies(), { ...options, account: account2 });
8249
8595
  });
8596
+ program.command("update").description("update this CLI with the package manager that installed it").option("--check", "only print this release and the latest one").option("--json", "with --check, print the two releases and the update command as JSON").action(
8597
+ async (options) => updateCommand(
8598
+ {
8599
+ current: CLI_VERSION,
8600
+ latest: () => readLatest(apiOrigin()),
8601
+ location: SCRIPT_LOCATION,
8602
+ globalRoot: readGlobalRoot,
8603
+ run: runCommand,
8604
+ output: (text) => console.log(text),
8605
+ notice: (text) => console.error(text)
8606
+ },
8607
+ options
8608
+ )
8609
+ );
8250
8610
  program.command("logout").description(
8251
8611
  "end this computer's CLI login and remove the saved login; Yield's capture here stops until the next login"
8252
8612
  ).action(async () => logoutCommand(dependencies()));
@@ -8646,31 +9006,31 @@ function createProgram(makeDependencies = createDependencies) {
8646
9006
  file.command("read <name> <path>").description("print a file of a Machine, waking it first").option("--output <file>", "write the bytes to this local file instead of stdout").option("--range <range>", "one byte range, such as bytes=0-1023").option("--expect-activation <activation>", "read only in this activation"),
8647
9007
  "readMachineFile"
8648
9008
  ).action(
8649
- async (name, path5, options) => machineFileReadCommand(dependencies(), name, path5, options)
9009
+ async (name, path6, options) => machineFileReadCommand(dependencies(), name, path6, options)
8650
9010
  );
8651
9011
  operationOptions(
8652
9012
  file.command("write <name> <path>").description("replace a file of a Machine with stdin or --from, atomically").option("--from <file>", "the local file to send instead of stdin").option("--mode <mode>", "the file's mode, four octal digits such as 0755").option("--if-match <etag>", "replace only the version with this ETag").option("--create", "only create the file; refuse when it exists").option("--expect-activation <activation>", "write only in this activation"),
8653
9013
  "writeMachineFile"
8654
9014
  ).action(
8655
- async (name, path5, options) => machineFileWriteCommand(dependencies(), name, path5, options)
9015
+ async (name, path6, options) => machineFileWriteCommand(dependencies(), name, path6, options)
8656
9016
  );
8657
9017
  operationOptions(
8658
9018
  file.command("stat <name> <path>").description("show a path's type, size, mode, owner and ETag").option("--expect-activation <activation>", "stat only in this activation"),
8659
9019
  "statMachineFile"
8660
9020
  ).action(
8661
- async (name, path5, options) => machineFileStatCommand(dependencies(), name, path5, options)
9021
+ async (name, path6, options) => machineFileStatCommand(dependencies(), name, path6, options)
8662
9022
  );
8663
9023
  operationOptions(
8664
9024
  file.command("list <name> <path>").description("list a directory of a Machine").option("--expect-activation <activation>", "list only in this activation"),
8665
9025
  "listMachineFiles"
8666
9026
  ).action(
8667
- async (name, path5, options) => machineFileListCommand(dependencies(), name, path5, options)
9027
+ async (name, path6, options) => machineFileListCommand(dependencies(), name, path6, options)
8668
9028
  );
8669
9029
  operationOptions(
8670
9030
  file.command("delete <name> <path>").description("delete a file, a symbolic link or an empty directory of a Machine").option("--recursive", "delete a directory and everything in it").option("--expect-activation <activation>", "delete only in this activation"),
8671
9031
  "deleteMachineFile"
8672
9032
  ).action(
8673
- async (name, path5, options) => machineFileDeleteCommand(dependencies(), name, path5, options)
9033
+ async (name, path6, options) => machineFileDeleteCommand(dependencies(), name, path6, options)
8674
9034
  );
8675
9035
  const hold = machine.command("hold").description("keep a Machine awake for work that has no open connection");
8676
9036
  operationOptions(
@@ -8807,4 +9167,14 @@ try {
8807
9167
  } catch (error) {
8808
9168
  console.error(error instanceof Error ? error.message : error);
8809
9169
  if (!process.exitCode) process.exitCode = 1;
9170
+ } finally {
9171
+ await showUpdateNotice(releaseWatch, {
9172
+ context: {
9173
+ env: process.env,
9174
+ argv: process.argv,
9175
+ stdoutIsTTY: process.stdout.isTTY === true,
9176
+ stderrIsTTY: process.stderr.isTTY === true
9177
+ },
9178
+ statePath: noticeStatePath()
9179
+ }).catch(() => void 0);
8810
9180
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.17.2",
3
+ "version": "0.17.3",
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": {