@tonbo/cli 0.10.0 → 0.10.1

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.
Files changed (3) hide show
  1. package/README.md +8 -176
  2. package/dist/bin/tonbo.js +1428 -128
  3. package/package.json +2 -1
package/dist/bin/tonbo.js CHANGED
@@ -372,6 +372,13 @@ var managementOperations = {
372
372
  "idempotencyKey": false,
373
373
  "cli": "tonbo member list"
374
374
  },
375
+ "listMemberDevices": {
376
+ "method": "GET",
377
+ "path": "/members/devices",
378
+ "pathParams": [],
379
+ "query": [],
380
+ "idempotencyKey": false
381
+ },
375
382
  "updateMember": {
376
383
  "method": "PATCH",
377
384
  "path": "/members/{subject}",
@@ -488,6 +495,36 @@ var managementOperations = {
488
495
  "idempotencyKey": false,
489
496
  "cli": "tonbo usage show"
490
497
  },
498
+ "getUsageSeries": {
499
+ "method": "GET",
500
+ "path": "/usage/series",
501
+ "pathParams": [],
502
+ "query": [
503
+ "days",
504
+ "hours",
505
+ "month",
506
+ "compare"
507
+ ],
508
+ "idempotencyKey": false,
509
+ "cli": "tonbo usage series"
510
+ },
511
+ "getUsageToday": {
512
+ "method": "GET",
513
+ "path": "/usage/today",
514
+ "pathParams": [],
515
+ "query": [],
516
+ "idempotencyKey": false,
517
+ "cli": "tonbo usage today"
518
+ },
519
+ "getSessionsStore": {
520
+ "method": "GET",
521
+ "path": "/sessions/store",
522
+ "pathParams": [],
523
+ "query": [
524
+ "capture"
525
+ ],
526
+ "idempotencyKey": false
527
+ },
491
528
  "listGitWorkspaces": {
492
529
  "method": "GET",
493
530
  "path": "/git/workspaces",
@@ -499,12 +536,242 @@ var managementOperations = {
499
536
  "idempotencyKey": false,
500
537
  "cli": "tonbo workspace list"
501
538
  },
539
+ "listProjectBindings": {
540
+ "method": "GET",
541
+ "path": "/projects/bindings",
542
+ "pathParams": [],
543
+ "query": [
544
+ "device_id",
545
+ "cursor",
546
+ "limit"
547
+ ],
548
+ "idempotencyKey": false
549
+ },
550
+ "connectProjectFolder": {
551
+ "method": "POST",
552
+ "path": "/projects/connect",
553
+ "pathParams": [],
554
+ "query": [],
555
+ "idempotencyKey": false
556
+ },
557
+ "disconnectProjectFolder": {
558
+ "method": "DELETE",
559
+ "path": "/projects/bindings/{binding_id}",
560
+ "pathParams": [
561
+ "binding_id"
562
+ ],
563
+ "query": [],
564
+ "idempotencyKey": false
565
+ },
566
+ "latestPersonalHandoffResult": {
567
+ "method": "GET",
568
+ "path": "/yield/sessions/{sessionId}/latest-result",
569
+ "pathParams": [
570
+ "sessionId"
571
+ ],
572
+ "query": [],
573
+ "idempotencyKey": false
574
+ },
575
+ "listHandoffs": {
576
+ "method": "GET",
577
+ "path": "/yield/handoffs",
578
+ "pathParams": [],
579
+ "query": [],
580
+ "idempotencyKey": false
581
+ },
582
+ "createPersonalHandoff": {
583
+ "method": "POST",
584
+ "path": "/yield/handoffs",
585
+ "pathParams": [],
586
+ "query": [],
587
+ "idempotencyKey": false
588
+ },
589
+ "listHandoffEvents": {
590
+ "method": "GET",
591
+ "path": "/yield/handoffs/events",
592
+ "pathParams": [],
593
+ "query": [
594
+ "after",
595
+ "wait"
596
+ ],
597
+ "idempotencyKey": false
598
+ },
599
+ "listTeamHandoffs": {
600
+ "method": "GET",
601
+ "path": "/yield/handoffs/team",
602
+ "pathParams": [],
603
+ "query": [],
604
+ "idempotencyKey": false
605
+ },
606
+ "showPersonalHandoff": {
607
+ "method": "GET",
608
+ "path": "/yield/handoffs/{id}",
609
+ "pathParams": [
610
+ "id"
611
+ ],
612
+ "query": [],
613
+ "idempotencyKey": false
614
+ },
615
+ "takePersonalHandoff": {
616
+ "method": "POST",
617
+ "path": "/yield/handoffs/{id}/take",
618
+ "pathParams": [
619
+ "id"
620
+ ],
621
+ "query": [],
622
+ "idempotencyKey": false
623
+ },
624
+ "markPersonalHandoffRestored": {
625
+ "method": "POST",
626
+ "path": "/yield/handoffs/{id}/restored",
627
+ "pathParams": [
628
+ "id"
629
+ ],
630
+ "query": [],
631
+ "idempotencyKey": false
632
+ },
633
+ "startPersonalHandoff": {
634
+ "method": "POST",
635
+ "path": "/yield/handoffs/{id}/started",
636
+ "pathParams": [
637
+ "id"
638
+ ],
639
+ "query": [],
640
+ "idempotencyKey": false
641
+ },
642
+ "needHumanPersonalHandoff": {
643
+ "method": "POST",
644
+ "path": "/yield/handoffs/{id}/need-human",
645
+ "pathParams": [
646
+ "id"
647
+ ],
648
+ "query": [],
649
+ "idempotencyKey": false
650
+ },
651
+ "cancelPersonalHandoff": {
652
+ "method": "POST",
653
+ "path": "/yield/handoffs/{id}/cancel",
654
+ "pathParams": [
655
+ "id"
656
+ ],
657
+ "query": [],
658
+ "idempotencyKey": false
659
+ },
660
+ "renewPersonalHandoffSource": {
661
+ "method": "POST",
662
+ "path": "/yield/handoffs/{id}/renew-source",
663
+ "pathParams": [
664
+ "id"
665
+ ],
666
+ "query": [],
667
+ "idempotencyKey": false
668
+ },
669
+ "finishPersonalHandoff": {
670
+ "method": "POST",
671
+ "path": "/yield/handoffs/{id}/done",
672
+ "pathParams": [
673
+ "id"
674
+ ],
675
+ "query": [],
676
+ "idempotencyKey": false
677
+ },
678
+ "pullPersonalHandoff": {
679
+ "method": "GET",
680
+ "path": "/yield/handoffs/{id}/result",
681
+ "pathParams": [
682
+ "id"
683
+ ],
684
+ "query": [],
685
+ "idempotencyKey": false
686
+ },
687
+ "markPersonalHandoffPulled": {
688
+ "method": "POST",
689
+ "path": "/yield/handoffs/{id}/pulled",
690
+ "pathParams": [
691
+ "id"
692
+ ],
693
+ "query": [],
694
+ "idempotencyKey": false
695
+ },
696
+ "renewPersonalHandoffResult": {
697
+ "method": "POST",
698
+ "path": "/yield/handoffs/{id}/renew-result",
699
+ "pathParams": [
700
+ "id"
701
+ ],
702
+ "query": [],
703
+ "idempotencyKey": false
704
+ },
705
+ "listYieldAccounts": {
706
+ "method": "GET",
707
+ "path": "/yield/accounts",
708
+ "pathParams": [],
709
+ "query": [],
710
+ "idempotencyKey": false
711
+ },
712
+ "createYieldOrganization": {
713
+ "method": "POST",
714
+ "path": "/yield/organizations",
715
+ "pathParams": [],
716
+ "query": [],
717
+ "idempotencyKey": false
718
+ },
719
+ "listYieldInvitations": {
720
+ "method": "GET",
721
+ "path": "/yield/invitations",
722
+ "pathParams": [],
723
+ "query": [],
724
+ "idempotencyKey": false
725
+ },
726
+ "createYieldInvitation": {
727
+ "method": "POST",
728
+ "path": "/yield/invitations",
729
+ "pathParams": [],
730
+ "query": [],
731
+ "idempotencyKey": false
732
+ },
733
+ "revokeYieldInvitation": {
734
+ "method": "DELETE",
735
+ "path": "/yield/invitations/{id}",
736
+ "pathParams": [
737
+ "id"
738
+ ],
739
+ "query": [],
740
+ "idempotencyKey": false
741
+ },
742
+ "recordYieldMetric": {
743
+ "method": "POST",
744
+ "path": "/yield/metrics",
745
+ "pathParams": [],
746
+ "query": [],
747
+ "idempotencyKey": false
748
+ },
749
+ "readYieldTurnExcerpt": {
750
+ "method": "GET",
751
+ "path": "/yield/excerpts",
752
+ "pathParams": [],
753
+ "query": [
754
+ "author",
755
+ "session_id",
756
+ "turn",
757
+ "path"
758
+ ],
759
+ "idempotencyKey": false
760
+ },
761
+ "publishYieldTurnExcerpt": {
762
+ "method": "POST",
763
+ "path": "/yield/excerpts",
764
+ "pathParams": [],
765
+ "query": [],
766
+ "idempotencyKey": false
767
+ },
502
768
  "listDatabases": {
503
769
  "method": "GET",
504
770
  "path": "/databases",
505
771
  "pathParams": [],
506
772
  "query": [
507
773
  "prefix",
774
+ "include",
508
775
  "cursor",
509
776
  "limit"
510
777
  ],
@@ -549,6 +816,20 @@ var managementOperations = {
549
816
  "idempotencyKey": false,
550
817
  "cli": "tonbo database delete"
551
818
  },
819
+ "getDatabaseActivity": {
820
+ "method": "GET",
821
+ "path": "/databases/{name}/usage",
822
+ "pathParams": [
823
+ "name"
824
+ ],
825
+ "query": [
826
+ "granularity",
827
+ "from",
828
+ "to"
829
+ ],
830
+ "idempotencyKey": false,
831
+ "cli": "tonbo database activity"
832
+ },
552
833
  "listDatabaseUsage": {
553
834
  "method": "GET",
554
835
  "path": "/usage/databases",
@@ -644,12 +925,49 @@ var managementOperations = {
644
925
  "idempotencyKey": false,
645
926
  "cli": "tonbo machine shapes"
646
927
  },
928
+ "listVolumes": {
929
+ "method": "GET",
930
+ "path": "/volumes",
931
+ "pathParams": [],
932
+ "query": [],
933
+ "idempotencyKey": false,
934
+ "cli": "tonbo volume list"
935
+ },
936
+ "createVolume": {
937
+ "method": "POST",
938
+ "path": "/volumes",
939
+ "pathParams": [],
940
+ "query": [],
941
+ "idempotencyKey": true,
942
+ "cli": "tonbo volume create"
943
+ },
944
+ "getVolume": {
945
+ "method": "GET",
946
+ "path": "/volumes/{name}",
947
+ "pathParams": [
948
+ "name"
949
+ ],
950
+ "query": [],
951
+ "idempotencyKey": false,
952
+ "cli": "tonbo volume show"
953
+ },
954
+ "deleteVolume": {
955
+ "method": "DELETE",
956
+ "path": "/volumes/{name}",
957
+ "pathParams": [
958
+ "name"
959
+ ],
960
+ "query": [],
961
+ "idempotencyKey": false,
962
+ "cli": "tonbo volume delete"
963
+ },
647
964
  "listStreams": {
648
965
  "method": "GET",
649
966
  "path": "/streams",
650
967
  "pathParams": [],
651
968
  "query": [
652
969
  "prefix",
970
+ "include",
653
971
  "cursor",
654
972
  "limit"
655
973
  ],
@@ -694,6 +1012,20 @@ var managementOperations = {
694
1012
  "idempotencyKey": false,
695
1013
  "cli": "tonbo stream delete"
696
1014
  },
1015
+ "getStreamActivity": {
1016
+ "method": "GET",
1017
+ "path": "/streams/{name}/usage",
1018
+ "pathParams": [
1019
+ "name"
1020
+ ],
1021
+ "query": [
1022
+ "granularity",
1023
+ "from",
1024
+ "to"
1025
+ ],
1026
+ "idempotencyKey": false,
1027
+ "cli": "tonbo stream activity"
1028
+ },
697
1029
  "listStreamUsage": {
698
1030
  "method": "GET",
699
1031
  "path": "/usage/streams",
@@ -1196,6 +1528,12 @@ async function assertPrivateRegularFile(filename) {
1196
1528
  var exec = promisify(execFile);
1197
1529
  var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
1198
1530
  var LOOPBACK_HOST = "127.0.0.1";
1531
+ var BrowserUnavailableError = class extends Error {
1532
+ };
1533
+ function prefersDeviceLogin(env = process.env) {
1534
+ const ssh = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
1535
+ return ssh && !env.DISPLAY && !env.WAYLAND_DISPLAY;
1536
+ }
1199
1537
  function newLoginSecret() {
1200
1538
  return randomBytes(32).toString("base64url");
1201
1539
  }
@@ -1210,13 +1548,17 @@ var AuthClient = class {
1210
1548
  browserOpener;
1211
1549
  hostname;
1212
1550
  loginHint;
1213
- constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
1551
+ entryLine;
1552
+ env;
1553
+ constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT, entryLine = "cli", env = process.env) {
1214
1554
  this.credentials = credentials;
1215
1555
  this.fetcher = fetcher;
1216
1556
  this.loginOrigin = loginOrigin2;
1217
1557
  this.browserOpener = browserOpener;
1218
1558
  this.hostname = hostname;
1219
1559
  this.loginHint = loginHint;
1560
+ this.entryLine = entryLine;
1561
+ this.env = env;
1220
1562
  }
1221
1563
  /** The personal key requests are made with: TONBO_API_KEY, or the saved
1222
1564
  * login while it has not expired. */
@@ -1236,12 +1578,16 @@ var AuthClient = class {
1236
1578
  * a free port, then the one-time code is redeemed, with this device's login
1237
1579
  * secret, for the account's login key, which replaces this device's earlier
1238
1580
  * login key there ("CLI on <hostname>" in a personal account, `Yield CLI ·
1239
- * <8 hex>` in any other). The key joins the file's `accounts`; it becomes
1240
- * the top level too unless `current` is false (a login made only to publish
1241
- * into an organization). Answers that account's login.
1581
+ * <8 hex>` in any other). With `device`, in an SSH session without a
1582
+ * display, or when no browser can be opened, the same PKCE challenge goes
1583
+ * to a device-code login instead: the person enters a short code at
1584
+ * /cli/device in any browser and the CLI polls for the key. The key joins
1585
+ * the file's `accounts`; it becomes the top level too unless `current` is
1586
+ * false (a login made only to publish into an organization). Answers that
1587
+ * account's login.
1242
1588
  */
1243
1589
  async login(progress = () => {
1244
- }, account, { current = true } = {}) {
1590
+ }, account, { current = true, device } = {}) {
1245
1591
  const existing = await this.credentials.load().catch((error) => {
1246
1592
  if (error instanceof UnusableLoginError)
1247
1593
  return null;
@@ -1250,6 +1596,30 @@ var AuthClient = class {
1250
1596
  const loginSecret = existing?.login_secret ?? newLoginSecret();
1251
1597
  const verifier = randomBytes(48).toString("base64url");
1252
1598
  const challenge = createHash("sha256").update(verifier).digest("base64url");
1599
+ const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, loginSecret, account, progress) : await this.browserLogin(challenge, verifier, loginSecret, account, progress).catch((error) => {
1600
+ if (!(error instanceof BrowserUnavailableError))
1601
+ throw error;
1602
+ progress({ status: "failed", step: "browser", reason: error.message });
1603
+ return this.deviceLogin(challenge, verifier, loginSecret, account, progress);
1604
+ });
1605
+ const login = {
1606
+ account_id: redeemed.account_id,
1607
+ key_id: redeemed.api_key.id,
1608
+ key: redeemed.key,
1609
+ expires_at: redeemed.api_key.expires_at,
1610
+ kind: redeemed.account_kind ?? null,
1611
+ name: redeemed.account_name ?? null
1612
+ };
1613
+ progress({ status: "started", step: "credential-store" });
1614
+ const latest = await this.credentials.load().catch(() => existing);
1615
+ await this.credentials.save(withAccountLogin(latest, login, {
1616
+ current,
1617
+ loginSecret: latest?.login_secret ?? loginSecret
1618
+ }));
1619
+ progress({ status: "completed", step: "credential-store" });
1620
+ return login;
1621
+ }
1622
+ async browserLogin(challenge, verifier, loginSecret, account, progress) {
1253
1623
  const state = randomBytes(24).toString("base64url");
1254
1624
  const code = await this.listenForCode(state, (redirectUri) => {
1255
1625
  const authorization = new URL("/cli/login", this.loginOrigin);
@@ -1259,6 +1629,7 @@ var AuthClient = class {
1259
1629
  redirect_uri: redirectUri,
1260
1630
  state,
1261
1631
  hostname: this.hostname,
1632
+ from: this.entryLine,
1262
1633
  ...account ? { account } : {}
1263
1634
  }).toString();
1264
1635
  return authorization.toString();
@@ -1271,34 +1642,85 @@ var AuthClient = class {
1271
1642
  body: JSON.stringify({ code, code_verifier: verifier, login_secret: loginSecret })
1272
1643
  });
1273
1644
  progress({ status: "completed", step: "key-redemption" });
1274
- const login = {
1275
- account_id: redeemed.account_id,
1276
- key_id: redeemed.api_key.id,
1277
- key: redeemed.key,
1278
- expires_at: redeemed.api_key.expires_at,
1279
- kind: redeemed.account_kind ?? null,
1280
- name: redeemed.account_name ?? null
1281
- };
1282
- progress({ status: "started", step: "credential-store" });
1283
- const latest = await this.credentials.load().catch(() => existing);
1284
- await this.credentials.save(withAccountLogin(latest, login, {
1285
- current,
1286
- loginSecret: latest?.login_secret ?? loginSecret
1287
- }));
1288
- progress({ status: "completed", step: "credential-store" });
1289
- return login;
1645
+ return redeemed;
1646
+ }
1647
+ /**
1648
+ * The device-code login (RFC 8628's shape): the PKCE challenge and the
1649
+ * hostname buy a device code and a user code; the person approves the user
1650
+ * code at /cli/device, seeing the hostname; the CLI polls with the device
1651
+ * code, its verifier and the login secret, all in POST bodies, and the
1652
+ * approved poll answers the key once. A denied or expired code ends it.
1653
+ */
1654
+ async deviceLogin(challenge, verifier, loginSecret, account, progress) {
1655
+ progress({ status: "started", step: "device-code" });
1656
+ const started = await requestJson(this.fetcher, new URL("/api/cli/login/device", this.loginOrigin).toString(), {
1657
+ method: "POST",
1658
+ headers: { "content-type": "application/json" },
1659
+ body: JSON.stringify({
1660
+ code_challenge: challenge,
1661
+ code_challenge_method: "S256",
1662
+ hostname: this.hostname
1663
+ })
1664
+ });
1665
+ progress({ status: "completed", step: "device-code" });
1666
+ const page = new URL("/cli/device", this.loginOrigin);
1667
+ const complete = new URL(page);
1668
+ complete.search = new URLSearchParams({
1669
+ code: started.user_code,
1670
+ from: this.entryLine,
1671
+ ...account ? { account } : {}
1672
+ }).toString();
1673
+ progress({
1674
+ status: "device-code",
1675
+ step: "device-authorization",
1676
+ userCode: started.user_code,
1677
+ url: page.toString(),
1678
+ completeUrl: complete.toString(),
1679
+ expiresIn: started.expires_in
1680
+ });
1681
+ progress({ status: "started", step: "device-authorization" });
1682
+ const expired = new Error(`The login code expired. Run \`${this.loginHint}\` again.`);
1683
+ const deadline = Date.now() + started.expires_in * 1e3;
1684
+ let interval = started.interval;
1685
+ for (; ; ) {
1686
+ await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
1687
+ try {
1688
+ const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
1689
+ method: "POST",
1690
+ headers: { "content-type": "application/json" },
1691
+ body: JSON.stringify({
1692
+ device_code: started.device_code,
1693
+ code_verifier: verifier,
1694
+ login_secret: loginSecret
1695
+ })
1696
+ });
1697
+ progress({ status: "completed", step: "device-authorization" });
1698
+ return redeemed;
1699
+ } catch (error) {
1700
+ const code = error instanceof HttpError ? error.code : null;
1701
+ if (code === "slow_down")
1702
+ interval += 5;
1703
+ else if (code === "access_denied")
1704
+ throw new Error("The login was not approved (access_denied).");
1705
+ else if (code === "expired_token")
1706
+ throw expired;
1707
+ else if (code !== "authorization_pending" && !transient(error))
1708
+ throw error;
1709
+ if (Date.now() >= deadline)
1710
+ throw expired;
1711
+ }
1712
+ }
1290
1713
  }
1291
1714
  listenForCode(expectedState, authorizationUrl, progress) {
1292
1715
  return new Promise((resolve, reject) => {
1293
1716
  let settled = false;
1294
1717
  const sockets = /* @__PURE__ */ new Set();
1295
1718
  let authorizationStarted = false;
1296
- const startAuthorization = (browserOpened = true) => {
1719
+ const startAuthorization = () => {
1297
1720
  if (authorizationStarted)
1298
1721
  return;
1299
1722
  authorizationStarted = true;
1300
- if (browserOpened)
1301
- progress({ status: "completed", step: "browser" });
1723
+ progress({ status: "completed", step: "browser" });
1302
1724
  progress({ status: "started", step: "browser-authorization" });
1303
1725
  };
1304
1726
  const finish = (result, completedResponseSocket) => {
@@ -1368,18 +1790,17 @@ var AuthClient = class {
1368
1790
  void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
1369
1791
  if (settled || authorizationStarted)
1370
1792
  return;
1371
- progress({
1372
- status: "failed",
1373
- step: "browser",
1374
- reason: error instanceof Error ? error.message : String(error),
1375
- callbackPort: port
1793
+ finish({
1794
+ error: new BrowserUnavailableError(error instanceof Error ? error.message : String(error))
1376
1795
  });
1377
- startAuthorization(false);
1378
1796
  });
1379
1797
  });
1380
1798
  });
1381
1799
  }
1382
1800
  };
1801
+ function transient(error) {
1802
+ return error instanceof HttpError ? error.status === 429 || error.status >= 500 : error instanceof TypeError;
1803
+ }
1383
1804
  function respond(response, outcome, finished) {
1384
1805
  const page = callbackResponse(outcome);
1385
1806
  if (finished)
@@ -1555,6 +1976,10 @@ var ManagementApi = class {
1555
1976
  deleteDatabase(key, name) {
1556
1977
  return this.call("deleteDatabase", key, { params: { name } });
1557
1978
  }
1979
+ /** A database's writes per hour or day and its size at each step's end. */
1980
+ getDatabaseActivity(key, name, query = {}) {
1981
+ return this.call("getDatabaseActivity", key, { params: { name }, query });
1982
+ }
1558
1983
  /** The whole usage page: its rows, `other`, prices and checkpoint. */
1559
1984
  listDatabaseUsage(key, page, query = {}) {
1560
1985
  return this.page("listDatabaseUsage", key, page, query);
@@ -1579,6 +2004,10 @@ var ManagementApi = class {
1579
2004
  deleteStream(key, name) {
1580
2005
  return this.call("deleteStream", key, { params: { name } });
1581
2006
  }
2007
+ /** A stream's appends per hour or day and its size at each step's end. */
2008
+ getStreamActivity(key, name, query = {}) {
2009
+ return this.call("getStreamActivity", key, { params: { name }, query });
2010
+ }
1582
2011
  /** The whole usage page: its rows, `other`, prices and checkpoint. */
1583
2012
  listStreamUsage(key, page, query = {}) {
1584
2013
  return this.page("listStreamUsage", key, page, query);
@@ -1586,6 +2015,13 @@ var ManagementApi = class {
1586
2015
  getUsage(key, period) {
1587
2016
  return this.call("getUsage", key, { query: { period } });
1588
2017
  }
2018
+ getUsageSeries(key, query = {}) {
2019
+ return this.call("getUsageSeries", key, { query });
2020
+ }
2021
+ /** The account's writes of yesterday and today, hourly, per lane. */
2022
+ getUsageToday(key) {
2023
+ return this.call("getUsageToday", key);
2024
+ }
1589
2025
  listMachines(key, page) {
1590
2026
  return this.page("listMachines", key, page);
1591
2027
  }
@@ -1715,6 +2151,22 @@ var ManagementApi = class {
1715
2151
  listMachineShapes(key) {
1716
2152
  return this.request("listMachineShapes", key, {});
1717
2153
  }
2154
+ /** Every Volume of the account, and the regions that could not be read. */
2155
+ listVolumes(key) {
2156
+ return this.request("listVolumes", key, {});
2157
+ }
2158
+ /** A replay with the same idempotency key answers the Volume the first
2159
+ * request created, so an unconfirmed create names the key to retry with. */
2160
+ createVolume(key, request, idempotencyKey) {
2161
+ return this.call("createVolume", key, { body: request, idempotencyKey }).catch(unconfirmed("Volume creation", idempotencyKey, "the same name, region and size"));
2162
+ }
2163
+ getVolume(key, name) {
2164
+ return this.call("getVolume", key, { params: { name } });
2165
+ }
2166
+ /** The Volume being deleted; refused (409) while it is bound. */
2167
+ deleteVolume(key, name) {
2168
+ return this.call("deleteVolume", key, { params: { name } });
2169
+ }
1718
2170
  };
1719
2171
  var CREATE_ATTEMPTS = 3;
1720
2172
  var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
@@ -1775,7 +2227,7 @@ function when(timestamp, absent = "never") {
1775
2227
  // ../../packages/tonbo-cli-core/dist/src/login.js
1776
2228
  async function loginCommand(deps, options = {}) {
1777
2229
  try {
1778
- const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current });
2230
+ const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current, device: options.device });
1779
2231
  deps.output(`Logged in to account ${login.name ?? login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
1780
2232
  return login;
1781
2233
  } catch (error) {
@@ -1804,6 +2256,14 @@ var LOGIN_PROGRESS_MESSAGES = {
1804
2256
  started: "Receiving the CLI key",
1805
2257
  completed: "CLI key received"
1806
2258
  },
2259
+ "device-code": {
2260
+ started: "Requesting a login code",
2261
+ completed: "Login code received"
2262
+ },
2263
+ "device-authorization": {
2264
+ started: "Waiting for approval in a browser",
2265
+ completed: "Login approved"
2266
+ },
1807
2267
  "credential-store": {
1808
2268
  started: "Saving login",
1809
2269
  completed: "Login saved"
@@ -1815,9 +2275,13 @@ function reportLoginProgress(progress, event) {
1815
2275
  return;
1816
2276
  }
1817
2277
  if (event.status === "failed") {
1818
- const port = event.callbackPort;
1819
- progress.fail(`Could not open a browser: ${event.reason.replace(/\.$/, "")}.`);
1820
- progress.note(`Open the URL above in a browser that can reach 127.0.0.1:${port} on this machine. From another computer, forward that port first: \`ssh -L ${port}:127.0.0.1:${port} <this host>\` or your VM's port forwarding both work.`);
2278
+ progress.fail(`Could not open a browser: ${event.reason.replace(/\.$/, "")}. Approve with a code instead.`);
2279
+ return;
2280
+ }
2281
+ if (event.status === "device-code") {
2282
+ const minutes = Math.max(1, Math.round(event.expiresIn / 60));
2283
+ progress.note(`To approve this login, open ${event.url} in any browser and enter the code ${event.userCode}. It expires in ${minutes} minutes.`);
2284
+ progress.note(`Or open ${event.completeUrl}`);
1821
2285
  return;
1822
2286
  }
1823
2287
  const messages = LOGIN_PROGRESS_MESSAGES[event.step];
@@ -2438,8 +2902,8 @@ async function databaseCreateCommand(deps, name, options = {}) {
2438
2902
  }
2439
2903
  function databaseNextStep(name) {
2440
2904
  return [
2441
- "Next: npm i --save-exact @tonbo/sqlite-ursula@0.11.2",
2442
- ` open(${JSON.stringify(name)}) from "@tonbo/sqlite-ursula/tonbo"`
2905
+ "Next: npm i --save-exact @tonbo/sdk@1.0.0 @tonbo/sqlite-ursula@0.11.4",
2906
+ ` openDatabase(${JSON.stringify(name)}) from "@tonbo/sdk/database"`
2443
2907
  ].join("\n");
2444
2908
  }
2445
2909
  async function databaseShowCommand(deps, name, options = {}) {
@@ -2523,11 +2987,48 @@ async function databaseUsageCommand(deps, options = {}) {
2523
2987
  );
2524
2988
  printResourceUsage(deps, options.json, usage, "database");
2525
2989
  }
2526
- function streamFields(stream) {
2527
- return fields([
2528
- ["Name", stream.name],
2529
- ["Region", stream.region],
2530
- ["State", stream.state],
2990
+ function stepValue(value) {
2991
+ return value === null ? "" : String(value);
2992
+ }
2993
+ function printActivity(deps, json, activity, noun) {
2994
+ print(
2995
+ deps,
2996
+ json,
2997
+ activity,
2998
+ [
2999
+ `${noun} ${activity.name} in ${activity.region}, ${activity.granularity === "day" ? "daily" : "hourly"} from ${activity.from} to ${activity.to} (UTC; metered up to ${when(activity.as_of, "not yet metered")}; last write ${when(activity.last_write_at)})`,
3000
+ table(
3001
+ ["START", "WRITE UNITS", "APPENDED", "RECORDS", "READS", "LIVE READS", "LOG", "SNAPSHOT"],
3002
+ activity.points.map((point) => [
3003
+ point.start,
3004
+ stepValue(point.write_units),
3005
+ point.append_bytes === null ? "" : bytes(point.append_bytes),
3006
+ stepValue(point.records),
3007
+ stepValue(point.reads),
3008
+ stepValue(point.live_reads),
3009
+ point.log_bytes === null ? "" : bytes(point.log_bytes),
3010
+ point.snapshot_bytes === null ? "" : bytes(point.snapshot_bytes)
3011
+ ])
3012
+ )
3013
+ ].join("\n")
3014
+ );
3015
+ }
3016
+ function activityQuery(options) {
3017
+ return { granularity: options.granularity, from: options.from, to: options.to };
3018
+ }
3019
+ async function databaseActivityCommand(deps, name, options = {}) {
3020
+ const activity = await deps.api.getDatabaseActivity(
3021
+ await deps.auth.apiKey(),
3022
+ name,
3023
+ activityQuery(options)
3024
+ );
3025
+ printActivity(deps, options.json, activity, "Database");
3026
+ }
3027
+ function streamFields(stream) {
3028
+ return fields([
3029
+ ["Name", stream.name],
3030
+ ["Region", stream.region],
3031
+ ["State", stream.state],
2531
3032
  ["Content type", stream.content_type],
2532
3033
  ["Expires", stream.expires_at ?? "never"],
2533
3034
  ["Read access", readAccess(stream)],
@@ -2636,6 +3137,83 @@ async function streamUsageCommand(deps, options = {}) {
2636
3137
  );
2637
3138
  printResourceUsage(deps, options.json, usage, "stream");
2638
3139
  }
3140
+ async function usageSeriesCommand(deps, options = {}) {
3141
+ const series = await deps.api.getUsageSeries(await deps.auth.apiKey(), {
3142
+ days: options.days,
3143
+ hours: options.hours,
3144
+ month: options.month,
3145
+ compare: options.compare ? "previous" : void 0
3146
+ });
3147
+ const at = (start) => series.granularity === "hour" ? start.slice(0, 16).replace("T", " ") : start.slice(0, 10);
3148
+ const month = series.month_to_date;
3149
+ const lines = [
3150
+ `Spend from ${at(series.start)} to ${at(series.end)} (UTC, ${series.currency.toUpperCase()})`,
3151
+ table(
3152
+ [
3153
+ series.granularity === "hour" ? "HOUR" : "DAY",
3154
+ "LIST",
3155
+ "ALLOWANCE",
3156
+ "CREDIT",
3157
+ "UNPAID",
3158
+ "CHARGED"
3159
+ ],
3160
+ series.points.map((point) => [
3161
+ at(point.start),
3162
+ usd(point.list_micros),
3163
+ usd(point.allowance_micros),
3164
+ usd(point.credit_micros),
3165
+ usd(point.outstanding_micros),
3166
+ usd(point.charged_micros)
3167
+ ])
3168
+ ),
3169
+ `Total: ${usd(series.totals.list_micros)} at list price, ${usd(series.totals.charged_micros)} charged beyond the allowance`
3170
+ ];
3171
+ if (series.previous) {
3172
+ const before = series.previous.totals.list_micros;
3173
+ const change = before ? ` (${series.totals.list_micros >= before ? "+" : ""}${Math.round((series.totals.list_micros - before) / before * 100)}%)` : "";
3174
+ lines.push(
3175
+ `Before: ${usd(before)} at list price from ${at(series.previous.start)} to ${at(series.previous.end)}${change}`
3176
+ );
3177
+ }
3178
+ lines.push(
3179
+ `${month.month} so far: ${usd(month.list_micros)} at list price, ${usd(month.allowance_micros)} of the ${usd(month.allowance_granted_micros)} allowance used, ${usd(month.charged_micros)} charged`
3180
+ );
3181
+ if (month.projection)
3182
+ lines.push(
3183
+ `At the rate of its ${month.projection.basis_days} complete days: ${usd(month.projection.list_micros)} at list price, ${usd(month.projection.charged_micros)} charged`
3184
+ );
3185
+ print(deps, options.json, series, lines.join("\n"));
3186
+ }
3187
+ async function streamActivityCommand(deps, name, options = {}) {
3188
+ const activity = await deps.api.getStreamActivity(
3189
+ await deps.auth.apiKey(),
3190
+ name,
3191
+ activityQuery(options)
3192
+ );
3193
+ printActivity(deps, options.json, activity, "Stream");
3194
+ }
3195
+ async function usageTodayCommand(deps, options = {}) {
3196
+ const today = await deps.api.getUsageToday(await deps.auth.apiKey());
3197
+ const { databases, streams, other } = today.series;
3198
+ print(
3199
+ deps,
3200
+ options.json,
3201
+ today,
3202
+ [
3203
+ `Write units per hour of yesterday and today (UTC; metered up to ${when(today.as_of, "not yet metered")})`,
3204
+ table(
3205
+ ["HOUR", "DATABASES", "STREAMS", "OTHER"],
3206
+ databases.map((point, index) => [
3207
+ point.start,
3208
+ stepValue(point.write_units),
3209
+ stepValue(streams[index]?.write_units ?? null),
3210
+ stepValue(other[index]?.write_units ?? null)
3211
+ ])
3212
+ )
3213
+ ].join("\n")
3214
+ );
3215
+ noticeUnavailable(deps, today.unavailable_regions, "usage");
3216
+ }
2639
3217
  async function usageShowCommand(deps, options = {}) {
2640
3218
  const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
2641
3219
  print(
@@ -2660,8 +3238,143 @@ async function usageShowCommand(deps, options = {}) {
2660
3238
  );
2661
3239
  }
2662
3240
 
3241
+ // src/help.ts
3242
+ import { Help } from "commander";
3243
+
2663
3244
  // src/machine.ts
3245
+ import { randomUUID as randomUUID4 } from "node:crypto";
3246
+
3247
+ // src/volume.ts
2664
3248
  import { randomUUID as randomUUID3 } from "node:crypto";
3249
+ var VOLUME_DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees/data#volume";
3250
+ var VOLUME_ERROR_HINTS = {
3251
+ payment_required: "Add credit at <billing>, then create the Volume again.",
3252
+ region_not_offered: "Volumes are not offered to this account in that region yet; nothing was created. Name a region where they are offered.",
3253
+ region_unavailable: "A region cannot be reached right now; nothing was created or changed. Try again shortly."
3254
+ };
3255
+ async function explainVolumeError(deps, error) {
3256
+ if (!(error instanceof HttpError) || error.code === null) return error;
3257
+ const hint = VOLUME_ERROR_HINTS[error.code];
3258
+ if (!hint) return explainMachineError(error);
3259
+ const text = hint.includes("<billing>") ? hint.replaceAll("<billing>", await billingUrl(deps)) : hint;
3260
+ return new HttpError(`${error.message}
3261
+ ${text}`, error.status, error.body, error.requestId);
3262
+ }
3263
+ async function volumeCommand(deps, run) {
3264
+ try {
3265
+ return await run();
3266
+ } catch (error) {
3267
+ throw await explainVolumeError(deps, error);
3268
+ }
3269
+ }
3270
+ function boundText(volume) {
3271
+ if (!volume.machine) return "none";
3272
+ return `${volume.machine.name}${volume.state === "attached" ? " (attached)" : ""}`;
3273
+ }
3274
+ function volumeFields(volume) {
3275
+ return fields([
3276
+ ["Name", volume.name],
3277
+ ["ID", volume.id],
3278
+ ["Region", volume.region],
3279
+ ["State", volume.state],
3280
+ ["Machine", boundText(volume)],
3281
+ ["Size", `${volume.size_gib} GiB`],
3282
+ ["Live bytes", `${bytes(volume.live_bytes)} (billed)`],
3283
+ ["Last commit", volume.last_commit_at ?? "none yet"],
3284
+ ["Durability", VOLUME_DURABILITY_LINK],
3285
+ ["Created", volume.created_at]
3286
+ ]);
3287
+ }
3288
+ function parseSizeGib(value) {
3289
+ const match = /^([0-9]+)(?:\s*gi?b?)?$/i.exec(value.trim());
3290
+ if (!match) throw new Error(`--size ${value}: give a whole number of GiB, such as 20.`);
3291
+ return Number(match[1]);
3292
+ }
3293
+ async function volumeListCommand(deps, options = {}) {
3294
+ await volumeCommand(deps, async () => {
3295
+ const list = await deps.api.listVolumes(await deps.auth.apiKey());
3296
+ print(
3297
+ deps,
3298
+ options.json,
3299
+ list.data,
3300
+ list.data.length ? table(
3301
+ ["NAME", "REGION", "STATE", "MACHINE", "SIZE", "LIVE BYTES"],
3302
+ list.data.map((volume) => [
3303
+ volume.name,
3304
+ volume.region,
3305
+ volume.state,
3306
+ volume.machine?.name ?? "-",
3307
+ `${volume.size_gib} GiB`,
3308
+ bytes(volume.live_bytes)
3309
+ ])
3310
+ ) : "No Volumes."
3311
+ );
3312
+ if (list.unavailable_regions.length)
3313
+ deps.notice(
3314
+ `Could not read the Volumes in ${list.unavailable_regions.join(", ")}; they are missing from this list.`
3315
+ );
3316
+ });
3317
+ }
3318
+ async function volumeShowCommand(deps, name, options = {}) {
3319
+ await volumeCommand(deps, async () => {
3320
+ const volume = await deps.api.getVolume(await deps.auth.apiKey(), name);
3321
+ print(deps, options.json, volume, volumeFields(volume));
3322
+ });
3323
+ }
3324
+ async function volumeCreateCommand(deps, name, options = {}) {
3325
+ await volumeCommand(deps, async () => {
3326
+ if (options.size === void 0) throw new Error("Pass --size <GiB>.");
3327
+ const sizeGib = parseSizeGib(options.size);
3328
+ const key = await deps.auth.apiKey();
3329
+ const region = options.region ?? (await deps.api.listRegions(key)).find((entry) => entry.default)?.id;
3330
+ if (!region) throw new Error("The account has no default region: pass --region.");
3331
+ const volume = await deps.api.createVolume(
3332
+ key,
3333
+ { name, region, size_gib: sizeGib },
3334
+ options.idempotencyKey ?? randomUUID3()
3335
+ );
3336
+ print(
3337
+ deps,
3338
+ options.json,
3339
+ volume,
3340
+ [
3341
+ `Created Volume ${volume.name} in ${volume.region} (${volume.size_gib} GiB; billed by live bytes, not size).`,
3342
+ `Mount it on a Machine of ${volume.region} with \`tonbo machine update <machine> --volume ${volume.name}:/data\`; it attaches at the Machine's next boot.`,
3343
+ volumeFields(volume)
3344
+ ].join("\n")
3345
+ );
3346
+ });
3347
+ }
3348
+ async function volumeDeleteCommand(deps, name, options = {}) {
3349
+ await volumeCommand(deps, async () => {
3350
+ const key = await deps.auth.apiKey();
3351
+ await confirm(
3352
+ deps,
3353
+ options.yes,
3354
+ `delete Volume ${name}`,
3355
+ () => Promise.resolve(
3356
+ `Delete Volume ${name} and every byte on it? There are no Volume snapshots or backups, so this cannot be undone. Type the Volume's name to confirm:`
3357
+ ),
3358
+ name
3359
+ );
3360
+ const volume = await deps.api.deleteVolume(key, name);
3361
+ print(
3362
+ deps,
3363
+ options.json,
3364
+ volume,
3365
+ `Volume ${volume.name} is being deleted; its name is free for a new Volume.`
3366
+ );
3367
+ });
3368
+ }
3369
+ async function resolveVolumeBinding(deps, key, spec) {
3370
+ const split = spec.indexOf(":");
3371
+ if (split < 1 || split === spec.length - 1)
3372
+ throw new Error(`--volume ${spec}: give <name>:<path>, such as data:/data.`);
3373
+ const volume = await deps.api.getVolume(key, spec.slice(0, split));
3374
+ return { binding: { volume: volume.id, path: spec.slice(split + 1) }, region: volume.region };
3375
+ }
3376
+
3377
+ // src/machine.ts
2665
3378
  var MACHINE_ERROR_HINTS = {
2666
3379
  region_not_offered: "Machines are not offered in that region yet; nothing was created or changed. Name a region where they are offered.",
2667
3380
  region_unavailable: "That region cannot be reached right now; nothing was created or changed. Try again shortly.",
@@ -2670,7 +3383,6 @@ var MACHINE_ERROR_HINTS = {
2670
3383
  hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
2671
3384
  account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
2672
3385
  image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
2673
- image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
2674
3386
  shape_not_offered: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2675
3387
  port_reserved: "Serve the application on another port.",
2676
3388
  precondition_required: "The API needs the Machine's ETag for this change; `tonbo machine show` prints it, and the command sends the ETag it reads unless --if-match names one.",
@@ -2678,11 +3390,13 @@ var MACHINE_ERROR_HINTS = {
2678
3390
  machine_failed: "A Machine whose creation failed does not change: `tonbo machine show` says why; delete it with `tonbo machine delete` and create it again.",
2679
3391
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
2680
3392
  database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
3393
+ machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`), or ask for more.",
2681
3394
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
2682
3395
  create_rate_limited: "Too many Machines were created in the last hour; try again later.",
2683
3396
  image_pulling: "The Machine's image is still being pulled; the request was not run. Try again shortly; `tonbo machine show` reports the image's state.",
2684
3397
  image_failed: "The Machine's image could not be pulled; `tonbo machine show` says why. Fix the image, then name it again with `tonbo machine update --image`.",
2685
- machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does."
3398
+ machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
3399
+ volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed."
2686
3400
  };
2687
3401
  var MACHINE_IMAGE_FAILURE_HINTS = {
2688
3402
  image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
@@ -2691,9 +3405,9 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
2691
3405
  image_unsupported: "Rebuild the image without the entry the message names"
2692
3406
  };
2693
3407
  var MACHINE_CREATION_FAILURE_HINTS = {
2694
- database_creation_failed: "The Machine's database could not be created; the message names the stream and the code. If the code is payment_required, add credit to the account first; then delete the Machine and create it again.",
3408
+ database_creation_failed: "The Machine's database could not be created; the message names the stream and the code. If the code is payment_required, add credit at <billing> first; then delete the Machine and create it again.",
2695
3409
  database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`), then delete this Machine and create it again.",
2696
- payment_required: "The account's credit is exhausted: add credit to the account, then delete the Machine and create it again.",
3410
+ payment_required: "The account's credit is exhausted: add credit at <billing>, then delete the Machine and create it again.",
2697
3411
  ...Object.fromEntries(
2698
3412
  Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
2699
3413
  code,
@@ -2701,10 +3415,10 @@ var MACHINE_CREATION_FAILURE_HINTS = {
2701
3415
  ])
2702
3416
  )
2703
3417
  };
2704
- function creationFailureText(machine) {
3418
+ function creationFailureText(machine, billing) {
2705
3419
  const { failure } = machine;
2706
3420
  if (!failure) return { reason: "", hint: "" };
2707
- const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code];
3421
+ const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code]?.replaceAll("<billing>", billing);
2708
3422
  return { reason: ` (${failure.code}: ${failure.message})`, hint: hint ? `
2709
3423
  ${hint}` : "" };
2710
3424
  }
@@ -2719,6 +3433,18 @@ function explainMachineError(error) {
2719
3433
  return new HttpError(`${error.message}
2720
3434
  ${hint}`, error.status, error.body, error.requestId);
2721
3435
  }
3436
+ var BILLING_URL = "https://tonbo.dev/{account}/billing";
3437
+ var BILLING_FALLBACK_URL = "https://tonbo.dev/billing";
3438
+ async function billingUrl(deps) {
3439
+ if (process.env.TONBO_API_KEY?.trim()) return BILLING_FALLBACK_URL;
3440
+ try {
3441
+ const login = await deps.credentials.load();
3442
+ const name = login ? accountLogins(login).find((entry) => entry.account_id === login.account_id)?.name : null;
3443
+ return name ? BILLING_URL.replace("{account}", encodeURIComponent(name)) : BILLING_FALLBACK_URL;
3444
+ } catch {
3445
+ return BILLING_FALLBACK_URL;
3446
+ }
3447
+ }
2722
3448
  async function machineCommand(run) {
2723
3449
  try {
2724
3450
  return await run();
@@ -2774,18 +3500,18 @@ function activationFields(machine) {
2774
3500
  return fields2;
2775
3501
  }
2776
3502
  var MACHINE_WAKE_BLOCKED_HINTS = {
2777
- payment_required: "The account's credit does not cover a wake: add credit, and the next request wakes it.",
2778
- standing: "The account's Streams standing is not solvent: settle the account, and the next request wakes it.",
3503
+ payment_required: "The account's credit does not cover a wake: add credit at <billing>, and the next request wakes it.",
3504
+ standing: "The account's Streams standing is not solvent: settle the account at <billing>, and the next request wakes it.",
2779
3505
  meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
2780
3506
  crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
2781
3507
  capacity: "No host had room for its shape; the next request tries again.",
2782
3508
  region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
2783
3509
  };
2784
- function conditionText(name, condition) {
3510
+ function conditionText(name, condition, billing) {
2785
3511
  switch (condition.type) {
2786
3512
  case "wake_blocked": {
2787
3513
  const retry = condition.retry_at ? `, retry after ${condition.retry_at}` : "";
2788
- const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name);
3514
+ const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name).replaceAll("<billing>", billing);
2789
3515
  return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}${retry}.${hint ? ` ${hint}` : ""}`;
2790
3516
  }
2791
3517
  case "creation_delayed":
@@ -2794,14 +3520,16 @@ function conditionText(name, condition) {
2794
3520
  return `its host is unreachable since ${condition.since}`;
2795
3521
  case "egress_blocked":
2796
3522
  return `outbound access blocked for the ${condition.scope} since ${condition.since}${condition.until ? ` until ${condition.until}` : ""}`;
3523
+ case "volume_purged":
3524
+ return `its Volume ${condition.volume} was purged (${condition.reason.replaceAll("_", " ")}) since ${condition.since}; its data is gone and it boots without it. Bind another with \`tonbo machine update ${name} --volume <volume>:<path>\`, or none with \`--no-volume\`.`;
2797
3525
  default:
2798
3526
  return JSON.stringify(condition);
2799
3527
  }
2800
3528
  }
2801
- function conditionFields(machine) {
3529
+ function conditionFields(machine, billing) {
2802
3530
  return (machine.status.conditions ?? []).map((condition) => [
2803
3531
  "Condition",
2804
- conditionText(machine.name, condition)
3532
+ conditionText(machine.name, condition, billing)
2805
3533
  ]);
2806
3534
  }
2807
3535
  function endText(end) {
@@ -2841,7 +3569,7 @@ function imageFailureFields(machine) {
2841
3569
  ...next ? [["Next step", next]] : []
2842
3570
  ];
2843
3571
  }
2844
- function machineFields(machine, etag = null, lastEnd = null) {
3572
+ function machineFields(machine, billing, etag = null, lastEnd = null) {
2845
3573
  return fields([
2846
3574
  ["Name", machine.name],
2847
3575
  ["ID", machine.id],
@@ -2850,7 +3578,7 @@ function machineFields(machine, etag = null, lastEnd = null) {
2850
3578
  ["Power", powerText(machine)],
2851
3579
  ...activationFields(machine),
2852
3580
  ...lastEnd ? [["Last end", endText(lastEnd)]] : [],
2853
- ...conditionFields(machine),
3581
+ ...conditionFields(machine, billing),
2854
3582
  ["Sleeps", sleepText(machine)],
2855
3583
  ["Shape", machine.shape],
2856
3584
  ["Image", imageText(machine)],
@@ -2863,6 +3591,7 @@ function machineFields(machine, etag = null, lastEnd = null) {
2863
3591
  ["SSH", `${machine.hostnames.ssh} (not served yet)`],
2864
3592
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
2865
3593
  ["Stop grace", `${machine.stop_grace_seconds} s`],
3594
+ ["Volume", volumeText(machine)],
2866
3595
  ["Database", `${machine.database.name} (${machine.database.url})`],
2867
3596
  ["Logs", machine.logs.url],
2868
3597
  ["Durability", DURABILITY_LINK],
@@ -2873,8 +3602,12 @@ function machineFields(machine, etag = null, lastEnd = null) {
2873
3602
  ["Updated", machine.updated_at]
2874
3603
  ]);
2875
3604
  }
2876
- function machineText(machine, etag = null, lastEnd = null) {
2877
- return [machineFields(machine, etag, lastEnd), ...privatePortLines(machine)].join("\n");
3605
+ function volumeText(machine) {
3606
+ const binding = machine.volumes?.[0];
3607
+ return binding ? `${binding.volume} at ${binding.path}` : "none";
3608
+ }
3609
+ function machineText(machine, billing, etag = null, lastEnd = null) {
3610
+ return [machineFields(machine, billing, etag, lastEnd), ...privatePortLines(machine)].join("\n");
2878
3611
  }
2879
3612
  function keyStateText(machine) {
2880
3613
  const state = machine.byoc.key_state;
@@ -2899,8 +3632,8 @@ function deviceFields(machine) {
2899
3632
  ["Updated", machine.updated_at]
2900
3633
  ]);
2901
3634
  }
2902
- function anyMachineText(machine, etag = null, lastEnd = null) {
2903
- return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag, lastEnd);
3635
+ function anyMachineText(machine, billing, etag = null, lastEnd = null) {
3636
+ return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, billing, etag, lastEnd);
2904
3637
  }
2905
3638
  function parsePorts(specs) {
2906
3639
  const accesses = machinePortAccess;
@@ -3064,9 +3797,10 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
3064
3797
  timeout,
3065
3798
  (read) => read.state !== "creating"
3066
3799
  );
3800
+ const billing = await billingUrl(deps);
3067
3801
  if (!settled) {
3068
3802
  const known = last ?? machine;
3069
- print(deps, json, known, machineFields(known));
3803
+ print(deps, json, known, machineFields(known, billing));
3070
3804
  throw new Error(
3071
3805
  `Machine ${machine.name} is still creating after ${waited} s; nothing was undone. \`tonbo machine show ${machine.name}\` reports it active when it is ready.`
3072
3806
  );
@@ -3079,14 +3813,14 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
3079
3813
  settled,
3080
3814
  [
3081
3815
  `Created ${settled.name} in ${settled.region} (active after ${waited} s${pulled}).`,
3082
- machineText(settled)
3816
+ machineText(settled, billing)
3083
3817
  ].join("\n")
3084
3818
  );
3085
3819
  return;
3086
3820
  }
3087
- print(deps, json, settled, machineFields(settled));
3821
+ print(deps, json, settled, machineFields(settled, billing));
3088
3822
  if (settled.state === "failed") {
3089
- const { reason, hint } = creationFailureText(settled);
3823
+ const { reason, hint } = creationFailureText(settled, billing);
3090
3824
  throw new Error(
3091
3825
  `Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
3092
3826
  );
@@ -3100,6 +3834,8 @@ async function machineCreateCommand(deps, name, command, options = {}) {
3100
3834
  const env = parseEnv(options.env ?? []);
3101
3835
  const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
3102
3836
  const key = await deps.auth.apiKey();
3837
+ const bound = typeof options.volume === "string" ? await resolveVolumeBinding(deps, key, options.volume) : null;
3838
+ const region = options.region ?? bound?.region;
3103
3839
  const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
3104
3840
  let created;
3105
3841
  try {
@@ -3109,15 +3845,16 @@ async function machineCreateCommand(deps, name, command, options = {}) {
3109
3845
  name,
3110
3846
  image: options.image,
3111
3847
  shape,
3112
- region: options.region,
3848
+ region,
3113
3849
  command: command.length ? command : void 0,
3114
3850
  ports: ports.length ? ports : void 0,
3115
3851
  env: Object.keys(env).length ? env : void 0,
3116
3852
  sleep_after_seconds: options.sleepAfter,
3117
3853
  idle_action: options.idleAction,
3118
- stop_grace_seconds: options.stopGrace
3854
+ stop_grace_seconds: options.stopGrace,
3855
+ ...bound ? { volumes: [bound.binding] } : {}
3119
3856
  },
3120
- options.idempotencyKey ?? randomUUID3()
3857
+ options.idempotencyKey ?? randomUUID4()
3121
3858
  );
3122
3859
  } catch (error) {
3123
3860
  if (error instanceof HttpError && error.code === "name_taken")
@@ -3127,8 +3864,9 @@ async function machineCreateCommand(deps, name, command, options = {}) {
3127
3864
  const { machine } = created;
3128
3865
  specWarnings(deps, machine.ports, Object.keys(env));
3129
3866
  if (machine.state === "failed") {
3130
- print(deps, options.json, machine, machineFields(machine));
3131
- const { reason, hint } = creationFailureText(machine);
3867
+ const billing = await billingUrl(deps);
3868
+ print(deps, options.json, machine, machineFields(machine, billing));
3869
+ const { reason, hint } = creationFailureText(machine, billing);
3132
3870
  throw new Error(
3133
3871
  `Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
3134
3872
  );
@@ -3143,7 +3881,12 @@ async function machineCreateCommand(deps, name, command, options = {}) {
3143
3881
  timeout,
3144
3882
  options.json
3145
3883
  );
3146
- print(deps, options.json, machine, [createdText(machine), machineText(machine)].join("\n"));
3884
+ print(
3885
+ deps,
3886
+ options.json,
3887
+ machine,
3888
+ [createdText(machine), machineText(machine, await billingUrl(deps))].join("\n")
3889
+ );
3147
3890
  });
3148
3891
  }
3149
3892
  async function machineShowCommand(deps, name, options = {}) {
@@ -3151,7 +3894,12 @@ async function machineShowCommand(deps, name, options = {}) {
3151
3894
  const key = await deps.auth.apiKey();
3152
3895
  const { machine, etag } = await deps.api.getMachineWithEtag(key, name);
3153
3896
  const lastEnd = options.json || machine.kind === "byoc" || machine.state !== "active" ? null : await lastActivationEnd(deps, key, machine.name);
3154
- print(deps, options.json, machine, anyMachineText(machine, etag, lastEnd));
3897
+ print(
3898
+ deps,
3899
+ options.json,
3900
+ machine,
3901
+ anyMachineText(machine, await billingUrl(deps), etag, lastEnd)
3902
+ );
3155
3903
  });
3156
3904
  }
3157
3905
  async function lastActivationEnd(deps, key, name) {
@@ -3194,7 +3942,7 @@ function appliesText(name, change) {
3194
3942
  const lines = [];
3195
3943
  if (change.ports !== void 0 || change.sleep_after_seconds !== void 0)
3196
3944
  lines.push("Ports and sleep-after apply now.");
3197
- const later = [change.image, change.command, change.shape, change.env].some(
3945
+ const later = [change.image, change.command, change.shape, change.env, change.volumes].some(
3198
3946
  (field) => field !== void 0
3199
3947
  );
3200
3948
  if (later) {
@@ -3242,6 +3990,9 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
3242
3990
  for (const variable of options.unsetEnv ?? []) delete env[variable];
3243
3991
  change.env = env;
3244
3992
  }
3993
+ if (options.volume === false) change.volumes = [];
3994
+ else if (typeof options.volume === "string")
3995
+ change.volumes = [(await resolveVolumeBinding(deps, key, options.volume)).binding];
3245
3996
  if (options.discardSnapshot) change.discard_snapshot = true;
3246
3997
  if (Object.values(change).every((value) => value === void 0))
3247
3998
  throw new Error("Nothing to change: pass at least one option or a command.");
@@ -3254,7 +4005,7 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
3254
4005
  [
3255
4006
  `Updated Machine ${machine.name} to revision ${machine.revision}.`,
3256
4007
  ...appliesText(machine.name, change),
3257
- machineText(machine)
4008
+ machineText(machine, await billingUrl(deps))
3258
4009
  ].join("\n")
3259
4010
  );
3260
4011
  });
@@ -3374,6 +4125,528 @@ async function machineShapesCommand(deps, options = {}) {
3374
4125
  });
3375
4126
  }
3376
4127
 
4128
+ // src/help.ts
4129
+ var ENVIRONMENT = [
4130
+ {
4131
+ names: ["TONBO_API_KEY"],
4132
+ description: "A personal key (`tbo_\u2026`) every command uses instead of the saved login, for non-interactive use such as CI. `tonbo logout` acts only on the saved login."
4133
+ },
4134
+ {
4135
+ names: ["TONBO_API_ORIGIN"],
4136
+ description: "The Management API origin, `https://api.tonbo.dev` when unset. It must be https, or http on a loopback address. `tonbo login` opens the login page on the same origin without its `api.` label."
4137
+ },
4138
+ {
4139
+ names: ["XDG_CONFIG_HOME"],
4140
+ description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/credentials.json` when it is set and not empty, else `$HOME/.config/tonbo/credentials.json`."
4141
+ },
4142
+ {
4143
+ names: ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"],
4144
+ description: "Any of them marks an SSH session: without `DISPLAY` or `WAYLAND_DISPLAY` too, `tonbo login` approves with a device code instead of opening a browser here."
4145
+ },
4146
+ {
4147
+ names: ["DISPLAY", "WAYLAND_DISPLAY"],
4148
+ description: "Outside macOS and Windows, `tonbo login` opens the browser with `xdg-open`, which needs a graphical session; with neither set it approves with a device code."
4149
+ }
4150
+ ];
4151
+ var TOPICS = [
4152
+ {
4153
+ id: "output",
4154
+ title: "Output and --json",
4155
+ text: [
4156
+ "Every command prints an aligned table or a list of fields for people. With `--json` it prints the answer's `data` exactly as the API returned it: one JSON value on stdout and nothing else there. Notices, next steps and warnings go to stderr. A command that prints the whole answer instead, because the fields beside `data` belong with the rows, says so."
4157
+ ]
4158
+ },
4159
+ {
4160
+ id: "pagination",
4161
+ title: "Lists and pages",
4162
+ text: [
4163
+ "A list prints one page. When there is another, the CLI writes `More results: run again with --cursor <cursor>` to stderr, so `--json` output stays the API's `data`; run the same command with that `--cursor` for the next page. `--limit` asks for up to that many items, 1 to 200; the API answers 50 when it is not given, and a page may hold fewer."
4164
+ ]
4165
+ },
4166
+ {
4167
+ id: "exit-status",
4168
+ title: "Exit status",
4169
+ text: [
4170
+ "0 when the command did what it says, and for `--help` and `--version`.",
4171
+ "1 on any failure: the API refused the request, an argument or flag is wrong, a destructive command was not confirmed, or a `--wait` ended without the state it waited for (a failure, or its `--timeout`). A refusal prints the problem's detail, the fields a validation error names and the request's `X-Request-Id` to stderr; an invalid or expired key adds `Run tonbo login`."
4172
+ ]
4173
+ },
4174
+ {
4175
+ id: "credentials",
4176
+ title: "Keys and the login file",
4177
+ text: [
4178
+ "Every command but `login` and `logout` sends a personal key as its Bearer credential: `TONBO_API_KEY` when it is set, else the key `tonbo login` saved. What a command may do is what that key's member may do in the account, as narrowed by the key; the CLI never decides it.",
4179
+ "The login is saved at `${XDG_CONFIG_HOME:-$HOME/.config}/tonbo/credentials.json`, which `yield` shares: either CLI's `login` signs both in, and either `logout` signs both out. The CLI replaces the file atomically with owner-only permissions and refuses a symlink or a file other users can read. A file an earlier release wrote is never migrated; run `tonbo login`."
4180
+ ]
4181
+ },
4182
+ {
4183
+ id: "retries",
4184
+ title: "Retried creations",
4185
+ text: [
4186
+ "`agent create`, `bucket create`, `database create`, `stream create`, `machine create` and `volume create` send an `Idempotency-Key`: a new UUID unless `--idempotency-key` names one. When a creation is not confirmed (the network failed or the server answered 5xx), the CLI prints its key; run the command again with that key and the same arguments, and it returns what the first request created instead of creating a second one. A key reused with other arguments is refused with `idempotency_conflict`.",
4187
+ "`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."
4188
+ ]
4189
+ },
4190
+ {
4191
+ id: "confirmations",
4192
+ title: "Destructive commands",
4193
+ text: [
4194
+ "`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."
4195
+ ]
4196
+ }
4197
+ ];
4198
+ var LIMIT_DEFAULT = "50, the API's default";
4199
+ var PERIOD_DEFAULT = "the current month";
4200
+ var DATA_REGION_DEFAULT = "the region `tonbo region list` marks as the database default";
4201
+ var ACTIVITY_DEFAULTS = {
4202
+ "--granularity": "hour",
4203
+ "--from": "24 hours (or 30 days for `day`) before --to",
4204
+ "--to": "the end of the current hour (or day)"
4205
+ };
4206
+ var USAGE_DEFAULTS = {
4207
+ "--period": PERIOD_DEFAULT,
4208
+ "--sort": "list_value",
4209
+ "--limit": LIMIT_DEFAULT
4210
+ };
4211
+ var EXPIRY_DEFAULTS = { "--expires-at": "never", "--expires-in": "never" };
4212
+ var NAME = (noun) => ({ name: `the ${noun}'s name` });
4213
+ var COMMAND_HELP = {
4214
+ "tonbo login": {
4215
+ about: [
4216
+ "Prints the login URL and opens the browser at `https://tonbo.dev/cli/login`, where you pick the account and approve a personal API key named `CLI on <hostname>`. The browser returns a one-time code to a local callback on `127.0.0.1`, and the CLI redeems it with its PKCE verifier for the key.",
4217
+ "The key acts as you, with your role in that account. It expires after six months and replaces the key an earlier login on the same computer created; there is no refresh, so an expired or revoked key asks you to run `tonbo login` again. `tonbo` acts in the account of the last login: log in again to switch. The login file keeps one key per account of this computer beside it, for `yield`; a key in an account other than your personal one is named `Yield CLI \xB7 <8 hex>` instead of after the host.",
4218
+ "When the browser is on another computer, the login uses a device code instead: with `--device`, in an SSH session without a display, or when no browser can be opened here. The CLI prints a short code and `https://tonbo.dev/cli/device`; open it in any browser where you are signed in, enter the code, check the computer name it shows, pick the account and approve. The CLI polls and saves the same key. The code works once and expires after ten minutes."
4219
+ ],
4220
+ examples: ["tonbo login", "tonbo login --device"],
4221
+ defaults: { "--account": "your personal account" }
4222
+ },
4223
+ "tonbo logout": {
4224
+ about: [
4225
+ "Revokes every account's key in the login file, each with its own credential, and removes the file, which signs `yield` out too. A key that is already revoked or expired is skipped. If another revocation fails, the file is still removed, and the CLI prints the key's id and exits 1, so you can revoke it under API keys in the console. `TONBO_API_KEY` is never touched."
4226
+ ],
4227
+ examples: ["tonbo logout"]
4228
+ },
4229
+ "tonbo account show": {
4230
+ about: [
4231
+ "Prints the account you are logged in to: its id, name and kind, your member subject and role in it, and its limits."
4232
+ ],
4233
+ examples: ["tonbo account show --json"]
4234
+ },
4235
+ "tonbo region list": {
4236
+ about: [
4237
+ "Shows each region new resources can be created in, with its data-plane origin, and marks the default region for new databases and streams."
4238
+ ],
4239
+ examples: ["tonbo region list"]
4240
+ },
4241
+ "tonbo workspace list": {
4242
+ about: [
4243
+ "Lists the account's git workspaces, the default first: each one's id, name, whether it is the default, its region and where it is served (`<git origin>/<ws_hex>`, the prefix of every repository URL in it). Every account has a default workspace from its creation. Listing needs only a role on the account and admits nothing; every git token is still admitted when it is minted."
4244
+ ],
4245
+ examples: ["tonbo workspace list"],
4246
+ defaults: { "--limit": LIMIT_DEFAULT }
4247
+ },
4248
+ "tonbo member list": {
4249
+ about: ["Lists the account's people and Agents with their kind, subject, name and role."],
4250
+ examples: ["tonbo member list --limit 20"],
4251
+ defaults: { "--limit": LIMIT_DEFAULT }
4252
+ },
4253
+ "tonbo member update": {
4254
+ about: [
4255
+ "Gives a member another role (`owner`, `admin` or `member`; only an owner grants `owner`), renames an Agent with `--name`, or both at once. Give at least one of them.",
4256
+ "`<member>` is a member subject: a person's user id, or `agent.<agent id>` for an Agent. An Agent's bare id or its name works too: the CLI looks it up in `tonbo member list` and uses it only when exactly one member matches."
4257
+ ],
4258
+ examples: ["tonbo member update pi-live --role admin"],
4259
+ arguments: { member: "a member subject, or an Agent's id or name" }
4260
+ },
4261
+ "tonbo agent create": {
4262
+ about: [
4263
+ "Creates an Agent identity with a role. Every Agent has a home region, chosen when it is created and never changed. Without `--region`, Tonbo uses the only region that accepts new Agents; when several do, an interactive `agent create` asks which one, and a request without `--region` from a script, or with `--json`, is refused.",
4264
+ "Create a key for the Agent with `tonbo api-key create --member <name>`."
4265
+ ],
4266
+ examples: ["tonbo agent create my-agent"],
4267
+ arguments: NAME("Agent"),
4268
+ defaults: {
4269
+ "--role": "member",
4270
+ "--region": "the only region that accepts new Agents",
4271
+ "--idempotency-key": "a new UUID"
4272
+ }
4273
+ },
4274
+ "tonbo api-key list": {
4275
+ about: [
4276
+ "Shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret, whether it is read-only, what it reaches of buckets, databases and streams, and when it expires and was last used."
4277
+ ],
4278
+ examples: ["tonbo api-key list"],
4279
+ defaults: { "--limit": LIMIT_DEFAULT }
4280
+ },
4281
+ "tonbo api-key create": {
4282
+ about: [
4283
+ "Creates a personal key for you, or with `--member` a key of an Agent (its subject, id or name; owners and admins only). `--read-only`, and either `--bucket` or any of `--database`, `--database-prefix`, `--stream` and `--stream-prefix` (repeat each for several), narrow the key for its whole life; a key narrowed to databases and streams reaches both, and `api-key list` and `api-key show` print what each key reaches.",
4284
+ "The secret is printed once, in this answer only, with a warning on stderr: store it then, because no command can show it again. Without `--json` the answer also carries a ready-to-paste `TONBO_API_KEY=<secret>` line for your application's environment, and stderr notes that `TONBO_API_KEY` in your own shell overrides your `tonbo login`, for this CLI too. stderr never carries the secret."
4285
+ ],
4286
+ examples: [
4287
+ "tonbo api-key create ci-reader --member pi-live --read-only --database-prefix run- --expires-in 6_months"
4288
+ ],
4289
+ arguments: { name: "the key's name, unique among the account's live keys" },
4290
+ defaults: { "--member": "yourself", "--expires-in": "never" }
4291
+ },
4292
+ "tonbo api-key show": {
4293
+ about: ["Prints one key's fields, as `api-key list` does; never its secret."],
4294
+ examples: ["tonbo api-key show 70000000-0000-4000-8000-000000000007"],
4295
+ arguments: { "key-id": "the key's id, as `tonbo api-key list` prints it" }
4296
+ },
4297
+ "tonbo api-key revoke": {
4298
+ about: [
4299
+ "Revokes the key and every token minted from it. At a terminal it asks you to type `yes` first."
4300
+ ],
4301
+ examples: ["tonbo api-key revoke 70000000-0000-4000-8000-000000000007 --yes"],
4302
+ arguments: { "key-id": "the key's id, as `tonbo api-key list` prints it" }
4303
+ },
4304
+ "tonbo bucket list": {
4305
+ about: [
4306
+ "Prints each bucket's region, state, read access and URL; a stream in it lives at `{url}/{stream}`."
4307
+ ],
4308
+ examples: ["tonbo bucket list"],
4309
+ defaults: { "--limit": LIMIT_DEFAULT }
4310
+ },
4311
+ "tonbo bucket create": {
4312
+ about: ["Creates a bucket in `--region` and prints its fields with its URL."],
4313
+ examples: ["tonbo bucket create events"],
4314
+ arguments: NAME("bucket"),
4315
+ defaults: { "--region": "the account's default region", "--idempotency-key": "a new UUID" }
4316
+ },
4317
+ "tonbo bucket show": {
4318
+ about: ["Prints the bucket's region, state, read access and URL."],
4319
+ examples: ["tonbo bucket show events"],
4320
+ arguments: NAME("bucket")
4321
+ },
4322
+ "tonbo bucket update": {
4323
+ about: [
4324
+ "Publishes the bucket for anonymous reads with `--public`, or takes it private with `--private`."
4325
+ ],
4326
+ examples: ["tonbo bucket update events --public"],
4327
+ arguments: NAME("bucket")
4328
+ },
4329
+ "tonbo bucket delete": {
4330
+ about: [
4331
+ "Deletes the bucket and every stream in it; the bucket's name is never used again. At a terminal it asks you to type the bucket's name first."
4332
+ ],
4333
+ examples: ["tonbo bucket delete events --yes"],
4334
+ arguments: NAME("bucket")
4335
+ },
4336
+ "tonbo database list": {
4337
+ about: [
4338
+ "Lists the account's databases in every region with their kind, region, state, read access, last write, stored size and URL. A region that cannot be read is named on stderr, and its sizes show as unknown."
4339
+ ],
4340
+ examples: ["tonbo database list --prefix run-"],
4341
+ defaults: { "--limit": LIMIT_DEFAULT }
4342
+ },
4343
+ "tonbo database create": {
4344
+ about: [
4345
+ 'Creates a database in `--region` and prints its fields with its URL. Without `--json` it then writes the next step to stderr: the packages to install and `openDatabase("<name>")` from `@tonbo/sdk/database`, which opens the database by name with your key.',
4346
+ "`--expires-at` (an RFC 3339 time, at least 60 seconds after the creation) or `--expires-in` (`90s` or more, such as `30m`, `24h` or `7d`, counted from now) gives the database an expiry fixed for its life: once it passes, Tonbo deletes the database within a minute, as `database delete` would, and its name is free again. `--expires-in` is counted from when the command runs, so it never goes with `--idempotency-key`: an unconfirmed creation with an expiry prints its key and the absolute time it sent, and the retry passes that time with `--expires-at`. A Machine's database never expires."
4347
+ ],
4348
+ examples: ["tonbo database create scratch --expires-in 24h"],
4349
+ arguments: { name: "the database's name, unique among the account's databases" },
4350
+ defaults: {
4351
+ "--region": DATA_REGION_DEFAULT,
4352
+ ...EXPIRY_DEFAULTS,
4353
+ "--idempotency-key": "a new UUID"
4354
+ }
4355
+ },
4356
+ "tonbo database show": {
4357
+ about: [
4358
+ "Prints the database's kind, region, state, read access, URL, expiry, incarnation, last write, stored size, and when and by whom it was created."
4359
+ ],
4360
+ examples: ["tonbo database show notes"],
4361
+ arguments: NAME("database")
4362
+ },
4363
+ "tonbo database update": {
4364
+ about: [
4365
+ "Publishes the database for anonymous reads with `--public`, or takes it private with `--private`. Owners and admins only."
4366
+ ],
4367
+ examples: ["tonbo database update notes --public"],
4368
+ arguments: NAME("database")
4369
+ },
4370
+ "tonbo database delete": {
4371
+ about: [
4372
+ "Deletes the database and everything in it; its name is free again. At a terminal it asks you to type the database's name first."
4373
+ ],
4374
+ examples: ["tonbo database delete scratch --yes"],
4375
+ arguments: NAME("database")
4376
+ },
4377
+ "tonbo database activity": {
4378
+ about: [
4379
+ "Prints one row per UTC hour or day of the window: the write units, bytes appended, records, successful reads and live (long-poll or SSE) reads of that step, and the log and snapshot size at its end. A step that has not started, or a size before the meter's first sample, is empty. The window is at most 31 days."
4380
+ ],
4381
+ examples: ["tonbo database activity notes --granularity day"],
4382
+ arguments: NAME("database"),
4383
+ defaults: ACTIVITY_DEFAULTS
4384
+ },
4385
+ "tonbo database usage": {
4386
+ about: [
4387
+ "Prints each database's usage of one calendar month in UTC at list price, before the monthly allowance and credit: its write units, log and snapshot GiB-months and list value, then the account's other metered usage (its streams and everything else). The charges after allowance and credit are in `tonbo usage show`."
4388
+ ],
4389
+ examples: ["tonbo database usage --period 2026-10 --sort name"],
4390
+ defaults: USAGE_DEFAULTS,
4391
+ json: "answer"
4392
+ },
4393
+ "tonbo stream list": {
4394
+ about: [
4395
+ "Lists the account's raw durable streams in every region with their region, state, content type, read access, last write, stored size and URL. A region that cannot be read is named on stderr."
4396
+ ],
4397
+ examples: ["tonbo stream list --prefix run-"],
4398
+ defaults: { "--limit": LIMIT_DEFAULT }
4399
+ },
4400
+ "tonbo stream create": {
4401
+ about: [
4402
+ "Creates a raw durable stream in `--region` with the content type every append must send (one lowercase `type/subtype` without parameters), and prints its fields with its URL on the region's Streams origin. Without `--json` it then writes the next step to stderr: append with `POST` to the URL and read with `GET` from `?offset=-1`, with any Durable Streams client.",
4403
+ "A stream and a database may share a name: each kind has its own names, limit and usage. `--expires-at` and `--expires-in` give the stream an expiry fixed for its life, by the same rules as `database create`'s: once it passes, Tonbo deletes the stream within a minute and its name is free again. A key narrowed to streams (`api-key create --stream` or `--stream-prefix`) reaches only those streams."
4404
+ ],
4405
+ examples: ["tonbo stream create events --content-type application/json"],
4406
+ arguments: { name: "the stream's name, unique among the account's streams" },
4407
+ defaults: {
4408
+ "--region": DATA_REGION_DEFAULT,
4409
+ "--content-type": "application/octet-stream",
4410
+ ...EXPIRY_DEFAULTS,
4411
+ "--idempotency-key": "a new UUID"
4412
+ }
4413
+ },
4414
+ "tonbo stream show": {
4415
+ about: [
4416
+ "Prints the stream's region, state, content type, expiry, read access, URL, incarnation, last write, stored size, and when and by whom it was created."
4417
+ ],
4418
+ examples: ["tonbo stream show events"],
4419
+ arguments: NAME("stream")
4420
+ },
4421
+ "tonbo stream update": {
4422
+ about: [
4423
+ "Publishes the stream for anonymous reads with `--public`, or takes it private with `--private`. Owners and admins only."
4424
+ ],
4425
+ examples: ["tonbo stream update events --private"],
4426
+ arguments: NAME("stream")
4427
+ },
4428
+ "tonbo stream delete": {
4429
+ about: [
4430
+ "Deletes the stream and everything in it; its name is free again. At a terminal it asks you to type the stream's name first."
4431
+ ],
4432
+ examples: ["tonbo stream delete run-42 --yes"],
4433
+ arguments: NAME("stream")
4434
+ },
4435
+ "tonbo stream activity": {
4436
+ about: [
4437
+ "Prints one row per UTC hour or day of the window: the write units, bytes appended, records, successful reads and live (long-poll or SSE) reads of that step, and the log and snapshot size at its end. A step that has not started, or a size before the meter's first sample, is empty. The window is at most 31 days."
4438
+ ],
4439
+ examples: ["tonbo stream activity events --from 2026-11-01T00:00:00Z"],
4440
+ arguments: NAME("stream"),
4441
+ defaults: ACTIVITY_DEFAULTS
4442
+ },
4443
+ "tonbo stream usage": {
4444
+ about: [
4445
+ "Prints each stream's usage of one calendar month in UTC at list price, before the monthly allowance and credit, then the account's other metered usage (its databases and everything else). The charges after allowance and credit are in `tonbo usage show`."
4446
+ ],
4447
+ examples: ["tonbo stream usage --names events,run-42"],
4448
+ defaults: USAGE_DEFAULTS,
4449
+ json: "answer"
4450
+ },
4451
+ "tonbo machine list": {
4452
+ about: [
4453
+ "Lists the account's Machines in every region with their region, state, power, shape, image, HTTPS hostname and conditions. In a personal account it also lists your own computers, which `yield login` registers, in a second table with their system, agent harnesses, when they last reported and the state of their login key. An organization lists Machines only."
4454
+ ],
4455
+ examples: ["tonbo machine list"],
4456
+ defaults: { "--limit": LIMIT_DEFAULT }
4457
+ },
4458
+ "tonbo machine create": {
4459
+ about: [
4460
+ "Creates a Machine, a VM booted from an image in one region with its own database. The image is a public OCI image with a linux/arm64 variant, from Docker Hub or another public registry (`nginx:1.27`, `ghcr.io/org/app:v1`, or one pinned by digest), or `tonbo.dev/platform`, the platform image. A registry reachable only over IPv6, one named by an IP address, and one that redirects its manifests are refused. The CLI resolves a registry image to its linux/arm64 digest, and the image is pulled before the Machine leaves `creating`.",
4461
+ "The Machine is served at `https://<name>-<account>.tonbo.sh` on its default port. `--port <port>` serves a private port, which takes a member's token, and `--port <port>:public` an open one, which needs the `machines.public` capability. A command after `--` replaces the image's CMD. The CLI warns on stderr when the Machine has no port, since nothing can wake it then, and when an `--env` key looks secret: env values are readable by every member through the API.",
4462
+ "The answer is the Machine while it is `creating`. With `--wait` the CLI reads it until it is `active` or `failed`, or `--timeout` passes: a failure prints what to do and exits 1, and a timeout says it is still creating and undoes nothing. A creation the network or a 5xx answer did not confirm is sent at most three times with the same idempotency key, each retry after its `Retry-After` (at most 30 s) or a short backoff; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply. In a region not open for Machines the creation is refused with `region_not_offered`, and nothing is created."
4463
+ ],
4464
+ examples: ["tonbo machine create web --image nginx:1.27 --port 80:public --wait"],
4465
+ arguments: {
4466
+ name: "the Machine's name, part of its hostname",
4467
+ command: "the command that replaces the image's CMD, after `--`"
4468
+ },
4469
+ defaults: {
4470
+ "--shape": "the shape `tonbo machine shapes` marks as the default",
4471
+ "--default-port": "the first --port",
4472
+ "--region": "the --volume's region, else the account's default region",
4473
+ "--idempotency-key": "a new UUID",
4474
+ "--timeout": String(DEFAULT_WAIT_SECONDS)
4475
+ }
4476
+ },
4477
+ "tonbo machine show": {
4478
+ about: [
4479
+ "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, its database and logs URLs, 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>`.",
4480
+ "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."
4481
+ ],
4482
+ examples: ["tonbo machine show web"],
4483
+ arguments: NAME("Machine")
4484
+ },
4485
+ "tonbo machine update": {
4486
+ about: [
4487
+ "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.",
4488
+ "Ports and `--sleep-after` apply at once. The image, command, shape and env 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."
4489
+ ],
4490
+ examples: ["tonbo machine update web --env MODE=staging --sleep-after 300"],
4491
+ arguments: {
4492
+ name: "the Machine's name",
4493
+ command: "the command that replaces the Machine's command at the next boot, after `--`"
4494
+ },
4495
+ defaults: {
4496
+ "--default-port": "the current default while --port still lists it, else the first --port",
4497
+ "--if-match": "the ETag the command reads"
4498
+ }
4499
+ },
4500
+ "tonbo machine sleep": {
4501
+ about: [
4502
+ "Puts an awake Machine to sleep now, and says so when it is already asleep or still creating; its next request boots it on its latest spec. With `--wait` the CLI reads the Machine until it is asleep or `--timeout` passes."
4503
+ ],
4504
+ examples: ["tonbo machine sleep web --wait"],
4505
+ arguments: NAME("Machine"),
4506
+ defaults: { "--timeout": String(DEFAULT_WAIT_SECONDS) }
4507
+ },
4508
+ "tonbo machine delete": {
4509
+ about: [
4510
+ "Deletes the Machine with its disk, database and logs; 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.",
4511
+ "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."
4512
+ ],
4513
+ examples: ["tonbo machine delete web --yes"],
4514
+ arguments: NAME("Machine"),
4515
+ defaults: { "--if-match": "the ETag read when asking for confirmation" }
4516
+ },
4517
+ "tonbo machine activations": {
4518
+ about: [
4519
+ "Lists the Machine's activations, newest first: the wake, what woke it, the spec revision and shape it ran, when it started and how it ended (its state, a reason such as `idle_early`, `workload_exited` with the exit code, or a refusal such as `payment_required`), or `live`."
4520
+ ],
4521
+ examples: ["tonbo machine activations web --limit 10"],
4522
+ arguments: NAME("Machine"),
4523
+ defaults: { "--limit": LIMIT_DEFAULT }
4524
+ },
4525
+ "tonbo machine shapes": {
4526
+ about: [
4527
+ "Lists the shapes a Machine may have, each with its vCPUs, memory, scratch disk, whether it is offered and can suspend, and its list price for an hour at full use, and marks the default."
4528
+ ],
4529
+ examples: ["tonbo machine shapes"],
4530
+ json: "answer"
4531
+ },
4532
+ "tonbo volume list": {
4533
+ about: [
4534
+ "Lists the account's Volumes in every region with each one's state, the Machine bound to it, its size and its live bytes."
4535
+ ],
4536
+ examples: ["tonbo volume list"]
4537
+ },
4538
+ "tonbo volume create": {
4539
+ about: [
4540
+ "Creates a Volume of `--size` GiB in one region. It is billed by its live bytes, not its size. Mount it on a Machine of the same region with `tonbo machine update <machine> --volume <name>:<path>`; it attaches at the Machine's next boot."
4541
+ ],
4542
+ examples: ["tonbo volume create data --size 20"],
4543
+ arguments: NAME("Volume"),
4544
+ defaults: { "--region": "the account's default region", "--idempotency-key": "a new UUID" }
4545
+ },
4546
+ "tonbo volume show": {
4547
+ about: [
4548
+ "Prints the Volume's region, state, the Machine it is bound to, its size, live bytes, last commit and a link to what it keeps."
4549
+ ],
4550
+ examples: ["tonbo volume show data"],
4551
+ arguments: NAME("Volume")
4552
+ },
4553
+ "tonbo volume delete": {
4554
+ about: [
4555
+ "Deletes the Volume and every byte on it; there are no Volume snapshots or backups. It is refused while a Machine has it. At a terminal it asks you to type the Volume's name first."
4556
+ ],
4557
+ examples: ["tonbo volume delete data --yes"],
4558
+ arguments: NAME("Volume")
4559
+ },
4560
+ "tonbo usage show": {
4561
+ about: [
4562
+ "Prints the account's rated usage of one calendar month in UTC: one line per product, region, dimension and model with its quantity and amount, and the total, in US dollars."
4563
+ ],
4564
+ examples: ["tonbo usage show --period 2026-10"],
4565
+ defaults: { "--period": PERIOD_DEFAULT }
4566
+ },
4567
+ "tonbo usage series": {
4568
+ about: [
4569
+ "Prints the account's spend one line per UTC day of the window, or one line per hour with `--hours`: at list price, what the monthly allowance and the credit balance paid, what is still unpaid, and what it cost beyond the allowance. Then the window's total, the month so far with its allowance and, after three complete days, the month at their run rate. Give at most one of `--days`, `--hours` and `--month`.",
4570
+ "`--compare` adds the window before, of the same length, and the change."
4571
+ ],
4572
+ examples: ["tonbo usage series --days 7 --compare"],
4573
+ defaults: { "--days": "30" }
4574
+ },
4575
+ "tonbo usage today": {
4576
+ about: [
4577
+ "Prints the account's write units per UTC hour of yesterday and today, in three columns: databases, streams and other."
4578
+ ],
4579
+ examples: ["tonbo usage today --json"]
4580
+ }
4581
+ };
4582
+ var WIDTH = 80;
4583
+ function wrap(text, indent) {
4584
+ const lines = [];
4585
+ let line = "";
4586
+ for (const word of text.split(/\s+/)) {
4587
+ if (line && indent.length + line.length + 1 + word.length > WIDTH) {
4588
+ lines.push(`${indent}${line}`);
4589
+ line = word;
4590
+ } else line = line ? `${line} ${word}` : word;
4591
+ }
4592
+ if (line) lines.push(`${indent}${line}`);
4593
+ return lines.join("\n");
4594
+ }
4595
+ var paragraphs = (texts) => texts.map((text) => wrap(text, " ")).join("\n\n");
4596
+ function commandHelpText(help) {
4597
+ return [
4598
+ "",
4599
+ "Details:",
4600
+ paragraphs(help.about),
4601
+ "",
4602
+ help.examples.length === 1 ? "Example:" : "Examples:",
4603
+ help.examples.map((example) => ` $ ${example}`).join("\n")
4604
+ ].join("\n");
4605
+ }
4606
+ function programHelpText() {
4607
+ return [
4608
+ ...TOPICS.flatMap((topic) => ["", `${topic.title}:`, paragraphs(topic.text)]),
4609
+ "",
4610
+ "Environment:",
4611
+ ENVIRONMENT.map(
4612
+ (variable) => ` ${variable.names.join(", ")}
4613
+ ${wrap(variable.description, " ")}`
4614
+ ).join("\n")
4615
+ ].join("\n");
4616
+ }
4617
+ function optionDescription(option) {
4618
+ const text = Help.prototype.optionDescription.call(this, option);
4619
+ return option.defaultValue === void 0 && option.defaultValueDescription ? `${text} (default: ${option.defaultValueDescription})` : text;
4620
+ }
4621
+ function applyHelp(program) {
4622
+ const named = /* @__PURE__ */ new Set();
4623
+ const walk = (command, words) => {
4624
+ const path2 = words.join(" ");
4625
+ command.configureHelp({ ...command.configureHelp(), optionDescription });
4626
+ const help = COMMAND_HELP[path2];
4627
+ if (help) {
4628
+ named.add(path2);
4629
+ for (const [name, text] of Object.entries(help.arguments ?? {})) {
4630
+ const argument = command.registeredArguments.find((entry) => entry.name() === name);
4631
+ if (!argument) throw new Error(`${path2} has no argument ${name}`);
4632
+ argument.description = text;
4633
+ }
4634
+ for (const [flag, text] of Object.entries(help.defaults ?? {})) {
4635
+ const option = command.options.find((entry) => entry.long === flag);
4636
+ if (!option) throw new Error(`${path2} has no option ${flag}`);
4637
+ option.defaultValueDescription = text;
4638
+ }
4639
+ command.addHelpText("after", commandHelpText(help));
4640
+ }
4641
+ for (const child of command.commands) walk(child, [...words, child.name()]);
4642
+ };
4643
+ walk(program, [program.name()]);
4644
+ for (const path2 of Object.keys(COMMAND_HELP))
4645
+ if (!named.has(path2)) throw new Error(`The help of ${path2} names no command`);
4646
+ program.addHelpText("after", programHelpText());
4647
+ return program;
4648
+ }
4649
+
3377
4650
  // src/prompt.ts
3378
4651
  import select from "@inquirer/select";
3379
4652
  import { createInterface } from "node:readline/promises";
@@ -3434,7 +4707,7 @@ function operationOptions(command, operationId) {
3434
4707
  command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
3435
4708
  return command.option("--json", "print the API data as JSON");
3436
4709
  }
3437
- var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes); the ETag the command reads, if it reads one, by default`;
4710
+ var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes)`;
3438
4711
  var repeated = (value, previous = []) => [...previous, value];
3439
4712
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
3440
4713
  function machineSpecOptions(command, update) {
@@ -3445,7 +4718,7 @@ function machineSpecOptions(command, update) {
3445
4718
  repeated
3446
4719
  ).option(
3447
4720
  "--default-port <port>",
3448
- `the port served at the Machine's hostname without a port prefix; the first --port by default${update ? ", or the current default while --port still lists it" : ""}`,
4721
+ "the port served at the Machine's hostname without a port prefix",
3449
4722
  parseWhole
3450
4723
  ).option(
3451
4724
  "--env <KEY=VALUE>",
@@ -3463,6 +4736,9 @@ function machineSpecOptions(command, update) {
3463
4736
  "--stop-grace <seconds>",
3464
4737
  "the seconds between SIGTERM and SIGKILL on a planned stop",
3465
4738
  parseWhole
4739
+ ).option(
4740
+ "--volume <name:path>",
4741
+ `mount the Volume <name> (see tonbo volume list) at <path>; a Machine has at most one, of its own region${update ? "; applies at the next boot" : ""}`
3466
4742
  );
3467
4743
  }
3468
4744
  function parseExpiresIn(value) {
@@ -3481,7 +4757,10 @@ function parseUsageSort(value) {
3481
4757
  }
3482
4758
  function createProgram(dependencies = createDependencies) {
3483
4759
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
3484
- program.command("login").description("sign in through the browser and save a personal key for this computer").option("--account <name>", "the account to log in to; defaults to your personal account").action(async (options) => {
4760
+ program.command("login").description("sign in through the browser and save a personal key for this computer").option("--account <name>", "the account to log in to").option(
4761
+ "--device",
4762
+ "approve with a code in a browser on any computer, as over SSH without a display"
4763
+ ).action(async (options) => {
3485
4764
  await loginCommand(dependencies(), options);
3486
4765
  });
3487
4766
  program.command("logout").description("revoke this computer's key and remove the saved login").action(async () => logoutCommand(dependencies()));
@@ -3516,7 +4795,7 @@ function createProgram(dependencies = createDependencies) {
3516
4795
  async (subject, options) => memberUpdateCommand(dependencies(), subject, options)
3517
4796
  );
3518
4797
  operationOptions(
3519
- program.command("agent").description("manage the Agents of your account").command("create <name>").description("create an agent").addOption(roleOption("the Agent's role; member by default")).option(
4798
+ program.command("agent").description("manage the Agents of your account").command("create <name>").description("create an agent").addOption(roleOption("the Agent's role")).option(
3520
4799
  "--region <region>",
3521
4800
  "permanent home region (see tonbo region list); required once several regions accept new agents"
3522
4801
  ).option(
@@ -3535,10 +4814,7 @@ function createProgram(dependencies = createDependencies) {
3535
4814
  async (options) => apiKeyListCommand(dependencies(), options)
3536
4815
  );
3537
4816
  operationOptions(
3538
- apiKey.command("create <name>").description("create a key and print its secret, once").option(
3539
- "--member <member>",
3540
- "an Agent (subject, id or name) to create the key for; yourself by default"
3541
- ).option("--read-only", "narrow the key to read-only actions, for its whole life").option(
4817
+ apiKey.command("create <name>").description("create a key and print its secret, once").option("--member <member>", "an Agent (subject, id or name) to create the key for").option("--read-only", "narrow the key to read-only actions, for its whole life").option(
3542
4818
  "--bucket <name>",
3543
4819
  "narrow the key to this bucket, for its whole life; repeat for several",
3544
4820
  repeated
@@ -3559,9 +4835,7 @@ function createProgram(dependencies = createDependencies) {
3559
4835
  "narrow the key to the raw streams whose name starts with this, for its whole life; repeat for several; never with --bucket",
3560
4836
  repeated
3561
4837
  ).addOption(
3562
- new Option("--expires-in <period>", "when the key expires; never by default").choices(
3563
- apiKeyExpirations
3564
- )
4838
+ new Option("--expires-in <period>", "when the key expires").choices(apiKeyExpirations)
3565
4839
  ),
3566
4840
  "createApiKey"
3567
4841
  ).action(
@@ -3581,10 +4855,7 @@ function createProgram(dependencies = createDependencies) {
3581
4855
  async (options) => bucketListCommand(dependencies(), options)
3582
4856
  );
3583
4857
  operationOptions(
3584
- bucket.command("create <name>").description("create a bucket").option(
3585
- "--region <region>",
3586
- "the bucket's region (see tonbo region list); the account's default region by default"
3587
- ).option(
4858
+ bucket.command("create <name>").description("create a bucket").option("--region <region>", "the bucket's region (see tonbo region list)").option(
3588
4859
  "--idempotency-key <key>",
3589
4860
  "reuse the key of an unconfirmed creation to get the bucket it created"
3590
4861
  ),
@@ -3615,18 +4886,15 @@ function createProgram(dependencies = createDependencies) {
3615
4886
  async (options) => databaseListCommand(dependencies(), options)
3616
4887
  );
3617
4888
  operationOptions(
3618
- database.command("create <name>").description("create a database; its region is fixed for its life").option(
3619
- "--region <region>",
3620
- "the database's region (see tonbo region list); the database default region by default"
3621
- ).addOption(
4889
+ database.command("create <name>").description("create a database; its region is fixed for its life").option("--region <region>", "the database's region (see tonbo region list)").addOption(
3622
4890
  new Option(
3623
4891
  "--expires-at <time>",
3624
- "delete the database after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z; never by default"
4892
+ "delete the database after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z"
3625
4893
  ).conflicts("expiresIn")
3626
4894
  ).addOption(
3627
4895
  new Option(
3628
4896
  "--expires-in <duration>",
3629
- "delete the database this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never by default; never with --idempotency-key"
4897
+ "delete the database this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never with --idempotency-key"
3630
4898
  ).argParser(parseExpiresIn).conflicts("expiresAt")
3631
4899
  ).option(
3632
4900
  "--idempotency-key <key>",
@@ -3655,10 +4923,15 @@ function createProgram(dependencies = createDependencies) {
3655
4923
  async (name, options) => databaseDeleteCommand(dependencies(), name, options)
3656
4924
  );
3657
4925
  operationOptions(
3658
- database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option(
3659
- "--period <month>",
3660
- "the calendar month in UTC, as YYYY-MM; the current one by default"
3661
- ).option("--sort <order>", "list_value (highest first, the default) or name", parseUsageSort).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
4926
+ database.command("activity <name>").description("show a database's writes per hour or day and its size at each step's end").addOption(
4927
+ new Option("--granularity <step>", "one row per UTC hour or day").choices(["hour", "day"])
4928
+ ).option("--from <time>", "the window's start, an RFC 3339 time on a UTC hour (or day)").option("--to <time>", "the window's end, exclusive, at most 31 days after --from"),
4929
+ "getDatabaseActivity"
4930
+ ).action(
4931
+ async (name, options) => databaseActivityCommand(dependencies(), name, options)
4932
+ );
4933
+ operationOptions(
4934
+ database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option("--period <month>", "the calendar month in UTC, as YYYY-MM").option("--sort <order>", "list_value (highest first) or name", parseUsageSort).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
3662
4935
  "listDatabaseUsage"
3663
4936
  ).action(
3664
4937
  async (options) => databaseUsageCommand(dependencies(), options)
@@ -3671,21 +4944,18 @@ function createProgram(dependencies = createDependencies) {
3671
4944
  async (options) => streamListCommand(dependencies(), options)
3672
4945
  );
3673
4946
  operationOptions(
3674
- stream.command("create <name>").description("create a stream; its region and content type are fixed for its life").option(
3675
- "--region <region>",
3676
- "the stream's region (see tonbo region list); the database default region by default"
3677
- ).option(
4947
+ stream.command("create <name>").description("create a stream; its region and content type are fixed for its life").option("--region <region>", "the stream's region (see tonbo region list)").option(
3678
4948
  "--content-type <type>",
3679
- "the media type every append sends, such as application/json; application/octet-stream by default"
4949
+ "the media type every append sends, such as application/json"
3680
4950
  ).addOption(
3681
4951
  new Option(
3682
4952
  "--expires-at <time>",
3683
- "delete the stream after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z; never by default"
4953
+ "delete the stream after this RFC 3339 time, at least 60 seconds after its creation, such as 2026-11-01T00:00:00Z"
3684
4954
  ).conflicts("expiresIn")
3685
4955
  ).addOption(
3686
4956
  new Option(
3687
4957
  "--expires-in <duration>",
3688
- "delete the stream this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never by default; never with --idempotency-key"
4958
+ "delete the stream this long after its creation, at least 90s: such as 90s, 30m, 24h or 7d; never with --idempotency-key"
3689
4959
  ).argParser(parseExpiresIn).conflicts("expiresAt")
3690
4960
  ).option(
3691
4961
  "--idempotency-key <key>",
@@ -3714,10 +4984,15 @@ function createProgram(dependencies = createDependencies) {
3714
4984
  async (name, options) => streamDeleteCommand(dependencies(), name, options)
3715
4985
  );
3716
4986
  operationOptions(
3717
- stream.command("usage").description("each stream's usage of one month at list price, before allowance and credit").option(
3718
- "--period <month>",
3719
- "the calendar month in UTC, as YYYY-MM; the current one by default"
3720
- ).option("--sort <order>", "list_value (highest first, the default) or name", parseUsageSort).option("--prefix <prefix>", "only streams whose name starts with this").option("--names <names>", "only these streams, up to 100 names, comma-separated"),
4987
+ stream.command("activity <name>").description("show a stream's writes per hour or day and its size at each step's end").addOption(
4988
+ new Option("--granularity <step>", "one row per UTC hour or day").choices(["hour", "day"])
4989
+ ).option("--from <time>", "the window's start, an RFC 3339 time on a UTC hour (or day)").option("--to <time>", "the window's end, exclusive, at most 31 days after --from"),
4990
+ "getStreamActivity"
4991
+ ).action(
4992
+ async (name, options) => streamActivityCommand(dependencies(), name, options)
4993
+ );
4994
+ operationOptions(
4995
+ stream.command("usage").description("each stream's usage of one month at list price, before allowance and credit").option("--period <month>", "the calendar month in UTC, as YYYY-MM").option("--sort <order>", "list_value (highest first) or name", parseUsageSort).option("--prefix <prefix>", "only streams whose name starts with this").option("--names <names>", "only these streams, up to 100 names, comma-separated"),
3721
4996
  "listStreamUsage"
3722
4997
  ).action(
3723
4998
  async (options) => streamUsageCommand(dependencies(), options)
@@ -3735,20 +5010,13 @@ function createProgram(dependencies = createDependencies) {
3735
5010
  "create a Machine from an image; [command...] after -- replaces the image's CMD"
3736
5011
  ).requiredOption(
3737
5012
  "--image <reference>",
3738
- "the image the Machine boots: tonbo.dev/platform, or a public linux/arm64 OCI image once registry images are offered"
3739
- ).option(
3740
- "--region <region>",
3741
- "the Machine's region, fixed for its life; the account's default region by default"
3742
- ),
5013
+ "the image the Machine boots: a public OCI image with a linux/arm64 variant (nginx:1.27, ghcr.io/org/app@sha256:...), or tonbo.dev/platform"
5014
+ ).option("--region <region>", "the Machine's region, fixed for its life"),
3743
5015
  false
3744
5016
  ).option(
3745
5017
  "--idempotency-key <key>",
3746
5018
  "reuse the key of an unconfirmed creation to get the Machine it created"
3747
- ).option("--wait", "wait until the Machine is active, or say why it failed").option(
3748
- "--timeout <seconds>",
3749
- `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3750
- parseWhole
3751
- ),
5019
+ ).option("--wait", "wait until the Machine is active, or say why it failed").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
3752
5020
  "createMachine"
3753
5021
  ).action(
3754
5022
  async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
@@ -3763,7 +5031,7 @@ function createProgram(dependencies = createDependencies) {
3763
5031
  machineSpecOptions(
3764
5032
  machine.command("update <name> [command...]").description(
3765
5033
  "change a Machine; [command...] after -- replaces its command at the next boot"
3766
- ).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"),
5034
+ ).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"),
3767
5035
  true
3768
5036
  ),
3769
5037
  "updateMachine"
@@ -3771,11 +5039,7 @@ function createProgram(dependencies = createDependencies) {
3771
5039
  async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
3772
5040
  );
3773
5041
  operationOptions(
3774
- machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option(
3775
- "--timeout <seconds>",
3776
- `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3777
- parseWhole
3778
- ),
5042
+ machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
3779
5043
  "sleepMachine"
3780
5044
  ).action(
3781
5045
  async (name, options) => machineSleepCommand(dependencies(), name, options)
@@ -3798,16 +5062,52 @@ function createProgram(dependencies = createDependencies) {
3798
5062
  machine.command("shapes").description("list the shapes a Machine may have"),
3799
5063
  "listMachineShapes"
3800
5064
  ).action(async (options) => machineShapesCommand(dependencies(), options));
5065
+ const volume = program.command("volume").description("the account's Volumes: durable disks a Machine of the same region mounts");
3801
5066
  operationOptions(
3802
- program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
3803
- "--period <month>",
3804
- "the calendar month in UTC, as YYYY-MM; the current one by default"
5067
+ volume.command("list").description("list the Volumes in every region"),
5068
+ "listVolumes"
5069
+ ).action(async (options) => volumeListCommand(dependencies(), options));
5070
+ operationOptions(
5071
+ volume.command("create <name>").description("create a Volume; billed by its live bytes, not its size").requiredOption("--size <GiB>", "the Volume's size in GiB, 1 to 100, fixed for its life").option("--region <region>", "the Volume's region, fixed for its life").option(
5072
+ "--idempotency-key <key>",
5073
+ "reuse the key of an unconfirmed creation to get the Volume it created"
3805
5074
  ),
5075
+ "createVolume"
5076
+ ).action(
5077
+ async (name, options) => volumeCreateCommand(dependencies(), name, options)
5078
+ );
5079
+ operationOptions(
5080
+ volume.command("show <name>").description("show a Volume, the Machine it is bound to and its live bytes"),
5081
+ "getVolume"
5082
+ ).action(
5083
+ async (name, options) => volumeShowCommand(dependencies(), name, options)
5084
+ );
5085
+ operationOptions(
5086
+ volume.command("delete <name>").description("delete a Volume and its data; refused while a Machine has it").option("--yes", "delete without asking; required when stdin is not a terminal"),
5087
+ "deleteVolume"
5088
+ ).action(
5089
+ async (name, options) => volumeDeleteCommand(dependencies(), name, options)
5090
+ );
5091
+ const usage = program.command("usage").description("the account's rated usage");
5092
+ operationOptions(
5093
+ usage.command("show").description("show one month's rated usage").option("--period <month>", "the calendar month in UTC, as YYYY-MM"),
3806
5094
  "getUsage"
3807
5095
  ).action(
3808
5096
  async (options) => usageShowCommand(dependencies(), options)
3809
5097
  );
3810
- return program;
5098
+ operationOptions(
5099
+ usage.command("series").description("show the account's spend day by day, or hour by hour").option("--days <count>", "the last 1 to 92 UTC days, today included", parseWhole).option("--hours <count>", "the last 1 to 48 hours, one line per hour", parseWhole).option("--month <month>", "one calendar month in UTC, as YYYY-MM").option("--compare", "also show the window before, and the change"),
5100
+ "getUsageSeries"
5101
+ ).action(
5102
+ async (options) => usageSeriesCommand(dependencies(), options)
5103
+ );
5104
+ operationOptions(
5105
+ usage.command("today").description(
5106
+ "show the account's write units per hour of yesterday and today, by databases, streams and other"
5107
+ ),
5108
+ "getUsageToday"
5109
+ ).action(async (options) => usageTodayCommand(dependencies(), options));
5110
+ return applyHelp(program);
3811
5111
  }
3812
5112
 
3813
5113
  // src/main.ts