@tonbo/cli 0.9.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 -172
  2. package/dist/bin/tonbo.js +1804 -175
  3. package/package.json +2 -1
package/dist/bin/tonbo.js CHANGED
@@ -202,6 +202,7 @@ var agentNameContract = {
202
202
  "artifacts",
203
203
  "auth",
204
204
  "inference",
205
+ "lb-check",
205
206
  "network-health",
206
207
  "sandbox-control",
207
208
  "status",
@@ -221,6 +222,7 @@ var agentNameContract = {
221
222
  "artifacts.tonbo.sh",
222
223
  "auth.tonbo.sh",
223
224
  "inference.tonbo.sh",
225
+ "lb-check.tonbo.sh",
224
226
  "network-health.tonbo.sh",
225
227
  "sandbox-control.tonbo.sh",
226
228
  "status.tonbo.sh",
@@ -370,6 +372,13 @@ var managementOperations = {
370
372
  "idempotencyKey": false,
371
373
  "cli": "tonbo member list"
372
374
  },
375
+ "listMemberDevices": {
376
+ "method": "GET",
377
+ "path": "/members/devices",
378
+ "pathParams": [],
379
+ "query": [],
380
+ "idempotencyKey": false
381
+ },
373
382
  "updateMember": {
374
383
  "method": "PATCH",
375
384
  "path": "/members/{subject}",
@@ -486,6 +495,36 @@ var managementOperations = {
486
495
  "idempotencyKey": false,
487
496
  "cli": "tonbo usage show"
488
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
+ },
489
528
  "listGitWorkspaces": {
490
529
  "method": "GET",
491
530
  "path": "/git/workspaces",
@@ -497,12 +536,242 @@ var managementOperations = {
497
536
  "idempotencyKey": false,
498
537
  "cli": "tonbo workspace list"
499
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
+ },
500
768
  "listDatabases": {
501
769
  "method": "GET",
502
770
  "path": "/databases",
503
771
  "pathParams": [],
504
772
  "query": [
505
773
  "prefix",
774
+ "include",
506
775
  "cursor",
507
776
  "limit"
508
777
  ],
@@ -547,6 +816,20 @@ var managementOperations = {
547
816
  "idempotencyKey": false,
548
817
  "cli": "tonbo database delete"
549
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
+ },
550
833
  "listDatabaseUsage": {
551
834
  "method": "GET",
552
835
  "path": "/usage/databases",
@@ -621,6 +904,19 @@ var managementOperations = {
621
904
  "idempotencyKey": false,
622
905
  "cli": "tonbo machine sleep"
623
906
  },
907
+ "listMachineActivations": {
908
+ "method": "GET",
909
+ "path": "/machines/{name}/activations",
910
+ "pathParams": [
911
+ "name"
912
+ ],
913
+ "query": [
914
+ "cursor",
915
+ "limit"
916
+ ],
917
+ "idempotencyKey": false,
918
+ "cli": "tonbo machine activations"
919
+ },
624
920
  "listMachineShapes": {
625
921
  "method": "GET",
626
922
  "path": "/machine-shapes",
@@ -629,12 +925,49 @@ var managementOperations = {
629
925
  "idempotencyKey": false,
630
926
  "cli": "tonbo machine shapes"
631
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
+ },
632
964
  "listStreams": {
633
965
  "method": "GET",
634
966
  "path": "/streams",
635
967
  "pathParams": [],
636
968
  "query": [
637
969
  "prefix",
970
+ "include",
638
971
  "cursor",
639
972
  "limit"
640
973
  ],
@@ -679,6 +1012,20 @@ var managementOperations = {
679
1012
  "idempotencyKey": false,
680
1013
  "cli": "tonbo stream delete"
681
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
+ },
682
1029
  "listStreamUsage": {
683
1030
  "method": "GET",
684
1031
  "path": "/usage/streams",
@@ -709,7 +1056,7 @@ var apiKeyExpirations = [
709
1056
  ];
710
1057
  var credentialsContract = {
711
1058
  "version": 1,
712
- "description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account. @tonbo/cli-core writes and reads it; other readers, such as @tonbo/sqlite-ursula/tonbo, follow `reader`.",
1059
+ "description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account at the top level, the login made last, and, beside it, every account's login of this device in `accounts` with the device's `login_secret` (ADR-0016, \"Credentials\", PR 4b-ii). @tonbo/cli-core writes and reads it; the Tonbo SDK (@tonbo/sdk and tonbo-sdk, contracts/sdk/v1.json) reads it as its last key source, and every other reader follows `reader` and reads the top level alone.",
713
1060
  "path": {
714
1061
  "description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
715
1062
  "base_env": "XDG_CONFIG_HOME",
@@ -747,6 +1094,13 @@ var credentialsContract = {
747
1094
  "expired": "A login whose `expires_at` is at or before now is expired: ask the person to run `tonbo login`. `expires_at: null` never expires.",
748
1095
  "login_hint": "tonbo login"
749
1096
  },
1097
+ "accounts": {
1098
+ "description": "One CLI login key per account (ADR-0016 R7 (a)): a daemon that publishes into an organization holds that organization's key beside the personal account's, since a key reaches only its own account. The fields are additive and optional, so a version 1 reader that knows only the top level keeps working, and the format stays version 1.",
1099
+ "top_level": "The login made last, by `tonbo login` or `yield login`: the account `tonbo` acts in. A login made only to publish into an organization (`yield connect --account`) is added to `accounts` and leaves the top level alone.",
1100
+ "entries": "`accounts` maps an account id to that account's login on this device, the top level's account included. `kind` and `name` are the redemption's answer; a file written before them, or by a server that does not answer them, has the top level alone, whose kind a client asks the API for.",
1101
+ "personal": "The entry whose kind is `user` is the person's personal account's login: it alone captures raw sessions and registers the device (ADR-0016 W-10, W-14).",
1102
+ "login_secret": "One random per-device secret, 32 bytes base64url, made at the first login and kept: it is sent only in the redeem POST, beside the PKCE verifier, never in the /cli/login URL, and User Center keeps only the suffix it derives from it, in an organization key's name `Yield CLI \xB7 <8 hex>`. `tonbo` and `yield` share it through this file, and so share one key per account. A lost file sends a new secret and gets a new suffix; the old key stays until it is revoked or idles out."
1103
+ },
750
1104
  "current_version": 1,
751
1105
  "schema": {
752
1106
  "$schema": "https://json-schema.org/draft/2020-12/schema",
@@ -787,6 +1141,59 @@ var credentialsContract = {
787
1141
  ],
788
1142
  "format": "date-time",
789
1143
  "description": "When the key expires, ISO 8601; null for a key that never expires."
1144
+ },
1145
+ "login_secret": {
1146
+ "type": "string",
1147
+ "pattern": "^[A-Za-z0-9_-]{43}$",
1148
+ "description": "This device's login secret (`accounts.login_secret`)."
1149
+ },
1150
+ "accounts": {
1151
+ "type": "object",
1152
+ "description": "Every account's login of this device, keyed by the account id (`accounts.entries`).",
1153
+ "additionalProperties": {
1154
+ "type": "object",
1155
+ "required": [
1156
+ "key_id",
1157
+ "key",
1158
+ "expires_at"
1159
+ ],
1160
+ "properties": {
1161
+ "key_id": {
1162
+ "type": "string",
1163
+ "minLength": 1,
1164
+ "description": "The key's id."
1165
+ },
1166
+ "key": {
1167
+ "type": "string",
1168
+ "pattern": "^tbo_",
1169
+ "description": "The personal key's secret."
1170
+ },
1171
+ "expires_at": {
1172
+ "type": [
1173
+ "string",
1174
+ "null"
1175
+ ],
1176
+ "format": "date-time",
1177
+ "description": "When the key expires; null for never."
1178
+ },
1179
+ "kind": {
1180
+ "type": [
1181
+ "string",
1182
+ "null"
1183
+ ],
1184
+ "pattern": "^(user|organization)$",
1185
+ "description": "The account's kind, `user` for the personal account; null when unknown."
1186
+ },
1187
+ "name": {
1188
+ "type": [
1189
+ "string",
1190
+ "null"
1191
+ ],
1192
+ "minLength": 1,
1193
+ "description": "The account's name, which `--account` takes; null when unknown."
1194
+ }
1195
+ }
1196
+ }
790
1197
  }
791
1198
  }
792
1199
  },
@@ -805,6 +1212,30 @@ var credentialsContract = {
805
1212
  "key_id": "20000000-0000-4000-8000-000000000002",
806
1213
  "key": "tbo_secret",
807
1214
  "expires_at": null
1215
+ },
1216
+ {
1217
+ "version": 1,
1218
+ "account_id": "10000000-0000-4000-8000-000000000001",
1219
+ "key_id": "20000000-0000-4000-8000-000000000002",
1220
+ "key": "tbo_secret",
1221
+ "expires_at": null,
1222
+ "login_secret": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1223
+ "accounts": {
1224
+ "10000000-0000-4000-8000-000000000001": {
1225
+ "key_id": "20000000-0000-4000-8000-000000000002",
1226
+ "key": "tbo_secret",
1227
+ "expires_at": null,
1228
+ "kind": "user",
1229
+ "name": "sam"
1230
+ },
1231
+ "10000000-0000-4000-8000-0000000000a1": {
1232
+ "key_id": "20000000-0000-4000-8000-0000000000a2",
1233
+ "key": "tbo_org",
1234
+ "expires_at": "2027-04-24T00:00:00.000Z",
1235
+ "kind": "organization",
1236
+ "name": "acme"
1237
+ }
1238
+ }
808
1239
  }
809
1240
  ],
810
1241
  "unusable": [
@@ -846,6 +1277,51 @@ var credentialsContract = {
846
1277
  "account_id": "10000000-0000-4000-8000-000000000001",
847
1278
  "key_id": "20000000-0000-4000-8000-000000000002",
848
1279
  "key": "tbo_secret"
1280
+ },
1281
+ {
1282
+ "version": 1,
1283
+ "account_id": "10000000-0000-4000-8000-000000000001",
1284
+ "key_id": "20000000-0000-4000-8000-000000000002",
1285
+ "key": "tbo_secret",
1286
+ "expires_at": null,
1287
+ "accounts": {
1288
+ "10000000-0000-4000-8000-0000000000a1": {
1289
+ "key_id": "k",
1290
+ "key": "not-a-personal-key",
1291
+ "expires_at": null
1292
+ }
1293
+ }
1294
+ },
1295
+ {
1296
+ "version": 1,
1297
+ "account_id": "10000000-0000-4000-8000-000000000001",
1298
+ "key_id": "20000000-0000-4000-8000-000000000002",
1299
+ "key": "tbo_secret",
1300
+ "expires_at": null,
1301
+ "accounts": {
1302
+ "10000000-0000-4000-8000-0000000000a1": {
1303
+ "key_id": "k",
1304
+ "key": "tbo_org",
1305
+ "expires_at": null,
1306
+ "kind": "team"
1307
+ }
1308
+ }
1309
+ },
1310
+ {
1311
+ "version": 1,
1312
+ "account_id": "10000000-0000-4000-8000-000000000001",
1313
+ "key_id": "20000000-0000-4000-8000-000000000002",
1314
+ "key": "tbo_secret",
1315
+ "expires_at": null,
1316
+ "accounts": []
1317
+ },
1318
+ {
1319
+ "version": 1,
1320
+ "account_id": "10000000-0000-4000-8000-000000000001",
1321
+ "key_id": "20000000-0000-4000-8000-000000000002",
1322
+ "key": "tbo_secret",
1323
+ "expires_at": null,
1324
+ "login_secret": "in-a-url"
849
1325
  }
850
1326
  ]
851
1327
  }
@@ -860,6 +1336,7 @@ var machinePortAccess = [
860
1336
  ];
861
1337
  var machineTimings = {
862
1338
  "pollAfterSeconds": 2,
1339
+ "sleepPollAfterSeconds": 5,
863
1340
  "wakeHoldSeconds": 25,
864
1341
  "earlyReclaimSeconds": 120
865
1342
  };
@@ -929,13 +1406,37 @@ var FileCredentialStore = class {
929
1406
  await rm(this.filename, { force: true });
930
1407
  }
931
1408
  };
932
- var RULE_KEYWORDS = /* @__PURE__ */ new Set(["const", "type", "minLength", "pattern", "format", "description"]);
1409
+ var RULE_KEYWORDS = /* @__PURE__ */ new Set([
1410
+ "const",
1411
+ "type",
1412
+ "minLength",
1413
+ "pattern",
1414
+ "format",
1415
+ "description",
1416
+ "additionalProperties"
1417
+ ]);
1418
+ function isPlainObject(value) {
1419
+ return !!value && typeof value === "object" && !Array.isArray(value);
1420
+ }
1421
+ function matchesObject(value, rule) {
1422
+ if (rule.type !== "object" || !isPlainObject(value))
1423
+ return false;
1424
+ if (!rule.required.every((name) => Object.hasOwn(value, name)))
1425
+ return false;
1426
+ return Object.entries(rule.properties).every(([name, inner]) => !Object.hasOwn(value, name) || matchesRule(value[name], inner));
1427
+ }
933
1428
  function matchesRule(value, rule) {
934
1429
  if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
935
1430
  return false;
936
1431
  if ("const" in rule)
937
1432
  return value === rule.const;
938
1433
  const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
1434
+ if (types.includes("object")) {
1435
+ if (!isPlainObject(value) || !rule.additionalProperties)
1436
+ return false;
1437
+ const entries = rule.additionalProperties;
1438
+ return Object.entries(value).every(([key, entry]) => key.length > 0 && matchesObject(entry, entries));
1439
+ }
939
1440
  if (value === null)
940
1441
  return types.includes("null");
941
1442
  if (typeof value !== "string" || !types.includes("string"))
@@ -949,7 +1450,7 @@ function matchesRule(value, rule) {
949
1450
  return true;
950
1451
  }
951
1452
  function isStoredLogin(value) {
952
- if (!value || typeof value !== "object" || Array.isArray(value))
1453
+ if (!isPlainObject(value))
953
1454
  return false;
954
1455
  const login = value;
955
1456
  const schema = credentialsContract.schema;
@@ -960,6 +1461,44 @@ function isStoredLogin(value) {
960
1461
  const properties = schema.properties;
961
1462
  return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
962
1463
  }
1464
+ function accountLogins(login) {
1465
+ const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
1466
+ ...entry,
1467
+ account_id,
1468
+ kind: entry.kind ?? null,
1469
+ name: entry.name ?? null
1470
+ }));
1471
+ if (!entries.some((entry) => entry.account_id === login.account_id))
1472
+ entries.unshift({
1473
+ account_id: login.account_id,
1474
+ key_id: login.key_id,
1475
+ key: login.key,
1476
+ expires_at: login.expires_at,
1477
+ kind: null,
1478
+ name: null
1479
+ });
1480
+ return entries;
1481
+ }
1482
+ function withAccountLogin(file, entry, options) {
1483
+ const accounts = {};
1484
+ if (file)
1485
+ for (const known of accountLogins(file)) {
1486
+ const { account_id: account_id2, ...rest2 } = known;
1487
+ accounts[account_id2] = rest2;
1488
+ }
1489
+ const { account_id, ...rest } = entry;
1490
+ accounts[account_id] = rest;
1491
+ const top = options.current || !file ? entry : file;
1492
+ return {
1493
+ version: 1,
1494
+ account_id: top.account_id,
1495
+ key_id: top.key_id,
1496
+ key: top.key,
1497
+ expires_at: top.expires_at,
1498
+ login_secret: options.loginSecret,
1499
+ accounts
1500
+ };
1501
+ }
963
1502
  async function preparePrivateDirectory(directory) {
964
1503
  const stat = await lstat(directory);
965
1504
  if (stat.isSymbolicLink() || !stat.isDirectory())
@@ -989,6 +1528,15 @@ async function assertPrivateRegularFile(filename) {
989
1528
  var exec = promisify(execFile);
990
1529
  var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
991
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
+ }
1537
+ function newLoginSecret() {
1538
+ return randomBytes(32).toString("base64url");
1539
+ }
992
1540
  function loginHostname(hostname = os2.hostname()) {
993
1541
  const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
994
1542
  return cleaned || "computer";
@@ -1000,13 +1548,17 @@ var AuthClient = class {
1000
1548
  browserOpener;
1001
1549
  hostname;
1002
1550
  loginHint;
1003
- 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) {
1004
1554
  this.credentials = credentials;
1005
1555
  this.fetcher = fetcher;
1006
1556
  this.loginOrigin = loginOrigin2;
1007
1557
  this.browserOpener = browserOpener;
1008
1558
  this.hostname = hostname;
1009
1559
  this.loginHint = loginHint;
1560
+ this.entryLine = entryLine;
1561
+ this.env = env;
1010
1562
  }
1011
1563
  /** The personal key requests are made with: TONBO_API_KEY, or the saved
1012
1564
  * login while it has not expired. */
@@ -1023,13 +1575,51 @@ var AuthClient = class {
1023
1575
  }
1024
1576
  /**
1025
1577
  * The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
1026
- * a free port, then the one-time code is redeemed for a personal key named
1027
- * "CLI on <hostname>", which replaces that host's earlier login key.
1578
+ * a free port, then the one-time code is redeemed, with this device's login
1579
+ * secret, for the account's login key, which replaces this device's earlier
1580
+ * login key there ("CLI on <hostname>" in a personal account, `Yield CLI ·
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.
1028
1588
  */
1029
1589
  async login(progress = () => {
1030
- }, account) {
1590
+ }, account, { current = true, device } = {}) {
1591
+ const existing = await this.credentials.load().catch((error) => {
1592
+ if (error instanceof UnusableLoginError)
1593
+ return null;
1594
+ throw error;
1595
+ });
1596
+ const loginSecret = existing?.login_secret ?? newLoginSecret();
1031
1597
  const verifier = randomBytes(48).toString("base64url");
1032
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) {
1033
1623
  const state = randomBytes(24).toString("base64url");
1034
1624
  const code = await this.listenForCode(state, (redirectUri) => {
1035
1625
  const authorization = new URL("/cli/login", this.loginOrigin);
@@ -1039,6 +1629,7 @@ var AuthClient = class {
1039
1629
  redirect_uri: redirectUri,
1040
1630
  state,
1041
1631
  hostname: this.hostname,
1632
+ from: this.entryLine,
1042
1633
  ...account ? { account } : {}
1043
1634
  }).toString();
1044
1635
  return authorization.toString();
@@ -1047,32 +1638,89 @@ var AuthClient = class {
1047
1638
  const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
1048
1639
  method: "POST",
1049
1640
  headers: { "content-type": "application/json" },
1050
- body: JSON.stringify({ code, code_verifier: verifier })
1641
+ // The secret goes only here, in the POST body, never in the URL.
1642
+ body: JSON.stringify({ code, code_verifier: verifier, login_secret: loginSecret })
1051
1643
  });
1052
1644
  progress({ status: "completed", step: "key-redemption" });
1053
- const login = {
1054
- version: 1,
1055
- account_id: redeemed.account_id,
1056
- key_id: redeemed.api_key.id,
1057
- key: redeemed.key,
1058
- expires_at: redeemed.api_key.expires_at
1059
- };
1060
- progress({ status: "started", step: "credential-store" });
1061
- await this.credentials.save(login);
1062
- progress({ status: "completed", step: "credential-store" });
1063
- 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
+ }
1064
1713
  }
1065
1714
  listenForCode(expectedState, authorizationUrl, progress) {
1066
1715
  return new Promise((resolve, reject) => {
1067
1716
  let settled = false;
1068
1717
  const sockets = /* @__PURE__ */ new Set();
1069
1718
  let authorizationStarted = false;
1070
- const startAuthorization = (browserOpened = true) => {
1719
+ const startAuthorization = () => {
1071
1720
  if (authorizationStarted)
1072
1721
  return;
1073
1722
  authorizationStarted = true;
1074
- if (browserOpened)
1075
- progress({ status: "completed", step: "browser" });
1723
+ progress({ status: "completed", step: "browser" });
1076
1724
  progress({ status: "started", step: "browser-authorization" });
1077
1725
  };
1078
1726
  const finish = (result, completedResponseSocket) => {
@@ -1142,18 +1790,17 @@ var AuthClient = class {
1142
1790
  void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
1143
1791
  if (settled || authorizationStarted)
1144
1792
  return;
1145
- progress({
1146
- status: "failed",
1147
- step: "browser",
1148
- reason: error instanceof Error ? error.message : String(error),
1149
- callbackPort: port
1793
+ finish({
1794
+ error: new BrowserUnavailableError(error instanceof Error ? error.message : String(error))
1150
1795
  });
1151
- startAuthorization(false);
1152
1796
  });
1153
1797
  });
1154
1798
  });
1155
1799
  }
1156
1800
  };
1801
+ function transient(error) {
1802
+ return error instanceof HttpError ? error.status === 429 || error.status >= 500 : error instanceof TypeError;
1803
+ }
1157
1804
  function respond(response, outcome, finished) {
1158
1805
  const page = callbackResponse(outcome);
1159
1806
  if (finished)
@@ -1329,6 +1976,10 @@ var ManagementApi = class {
1329
1976
  deleteDatabase(key, name) {
1330
1977
  return this.call("deleteDatabase", key, { params: { name } });
1331
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
+ }
1332
1983
  /** The whole usage page: its rows, `other`, prices and checkpoint. */
1333
1984
  listDatabaseUsage(key, page, query = {}) {
1334
1985
  return this.page("listDatabaseUsage", key, page, query);
@@ -1353,6 +2004,10 @@ var ManagementApi = class {
1353
2004
  deleteStream(key, name) {
1354
2005
  return this.call("deleteStream", key, { params: { name } });
1355
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
+ }
1356
2011
  /** The whole usage page: its rows, `other`, prices and checkpoint. */
1357
2012
  listStreamUsage(key, page, query = {}) {
1358
2013
  return this.page("listStreamUsage", key, page, query);
@@ -1360,6 +2015,13 @@ var ManagementApi = class {
1360
2015
  getUsage(key, period) {
1361
2016
  return this.call("getUsage", key, { query: { period } });
1362
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
+ }
1363
2025
  listMachines(key, page) {
1364
2026
  return this.page("listMachines", key, page);
1365
2027
  }
@@ -1452,12 +2114,15 @@ var ManagementApi = class {
1452
2114
  });
1453
2115
  }
1454
2116
  /** With `etag`, only the Machine that ETag names, at that revision, is put
1455
- * to sleep (machine-v2.json `etag.if_match.sleep` is optional). */
1456
- sleepMachine(key, name, etag) {
1457
- return this.call("sleepMachine", key, {
1458
- params: { name },
1459
- ...etag ? { ifMatch: etag } : {}
2117
+ * to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
2118
+ * carries the `Retry-After` to read the Machine again after
2119
+ * (machine-v2.json `sleep.poll_after_seconds`); a 200 carries none. */
2120
+ async sleepMachine(key, name, etag) {
2121
+ const headers = { retryAfter: null };
2122
+ const answer = await this.request("sleepMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
2123
+ headers.retryAfter = response.headers.get("retry-after");
1460
2124
  });
2125
+ return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
1461
2126
  }
1462
2127
  /** The Machine being deleted or the device forgotten, with its kind; the
1463
2128
  * Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
@@ -1474,10 +2139,34 @@ var ManagementApi = class {
1474
2139
  return { machine: answer.data, kind: answer.data.kind };
1475
2140
  return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
1476
2141
  }
2142
+ /** One page of a managed Machine's activations, newest first; the cursor
2143
+ * pages that Machine only. */
2144
+ listMachineActivations(key, name, { limit, cursor } = {}) {
2145
+ return this.request("listMachineActivations", key, {
2146
+ params: { name },
2147
+ query: { limit, cursor }
2148
+ });
2149
+ }
1477
2150
  /** The whole answer: the shapes and the default one. */
1478
2151
  listMachineShapes(key) {
1479
2152
  return this.request("listMachineShapes", key, {});
1480
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
+ }
1481
2170
  };
1482
2171
  var CREATE_ATTEMPTS = 3;
1483
2172
  var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
@@ -1538,8 +2227,9 @@ function when(timestamp, absent = "never") {
1538
2227
  // ../../packages/tonbo-cli-core/dist/src/login.js
1539
2228
  async function loginCommand(deps, options = {}) {
1540
2229
  try {
1541
- const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account);
1542
- deps.output(`Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
2230
+ const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current, device: options.device });
2231
+ deps.output(`Logged in to account ${login.name ?? login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
2232
+ return login;
1543
2233
  } catch (error) {
1544
2234
  deps.progress.fail();
1545
2235
  throw error;
@@ -1566,6 +2256,14 @@ var LOGIN_PROGRESS_MESSAGES = {
1566
2256
  started: "Receiving the CLI key",
1567
2257
  completed: "CLI key received"
1568
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
+ },
1569
2267
  "credential-store": {
1570
2268
  started: "Saving login",
1571
2269
  completed: "Login saved"
@@ -1577,9 +2275,13 @@ function reportLoginProgress(progress, event) {
1577
2275
  return;
1578
2276
  }
1579
2277
  if (event.status === "failed") {
1580
- const port = event.callbackPort;
1581
- progress.fail(`Could not open a browser: ${event.reason.replace(/\.$/, "")}.`);
1582
- 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}`);
1583
2285
  return;
1584
2286
  }
1585
2287
  const messages = LOGIN_PROGRESS_MESSAGES[event.step];
@@ -1603,17 +2305,23 @@ async function logoutCommand(deps) {
1603
2305
  deps.output("Not logged in.");
1604
2306
  return;
1605
2307
  }
1606
- let failure;
1607
- try {
1608
- await deps.api.revokeApiKey(login.key, login.key_id);
1609
- } catch (error) {
1610
- if (!(error instanceof HttpError && (error.status === 401 || error.status === 404)))
1611
- failure = error instanceof Error ? error : new Error("The revocation failed.");
2308
+ const failures = [];
2309
+ let first;
2310
+ for (const entry of accountLogins(login)) {
2311
+ try {
2312
+ await deps.api.revokeApiKey(entry.key, entry.key_id);
2313
+ } catch (error) {
2314
+ if (error instanceof HttpError && (error.status === 401 || error.status === 404))
2315
+ continue;
2316
+ const failure = error instanceof Error ? error : new Error("The revocation failed.");
2317
+ first ??= failure;
2318
+ failures.push(`${entry.key_id}: ${failure.message}`);
2319
+ }
1612
2320
  }
1613
2321
  await deps.credentials.remove();
1614
- if (failure)
1615
- throw new Error(`Removed the saved login, but could not revoke its key ${login.key_id}: ${failure.message} Revoke it under API keys in the Tonbo console.`, { cause: failure });
1616
- deps.output("Logged out. The CLI key is revoked.");
2322
+ if (failures.length)
2323
+ throw new Error(`Removed the saved login, but could not revoke ${failures.length === 1 ? "its key" : "these keys"} ${failures.join("; ")} Revoke ${failures.length === 1 ? "it" : "them"} under API keys in the Tonbo console.`, { cause: first });
2324
+ deps.output(login.accounts && Object.keys(login.accounts).length > 1 ? "Logged out. Every account's CLI key on this computer is revoked." : "Logged out. The CLI key is revoked.");
1617
2325
  }
1618
2326
 
1619
2327
  // ../../packages/tonbo-cli-core/dist/src/origin.js
@@ -2194,8 +2902,8 @@ async function databaseCreateCommand(deps, name, options = {}) {
2194
2902
  }
2195
2903
  function databaseNextStep(name) {
2196
2904
  return [
2197
- "Next: npm i --save-exact @tonbo/sqlite-ursula@0.11.2",
2198
- ` 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"`
2199
2907
  ].join("\n");
2200
2908
  }
2201
2909
  async function databaseShowCommand(deps, name, options = {}) {
@@ -2279,6 +2987,43 @@ async function databaseUsageCommand(deps, options = {}) {
2279
2987
  );
2280
2988
  printResourceUsage(deps, options.json, usage, "database");
2281
2989
  }
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
+ }
2282
3027
  function streamFields(stream) {
2283
3028
  return fields([
2284
3029
  ["Name", stream.name],
@@ -2384,40 +3129,252 @@ async function streamDeleteCommand(deps, name, options = {}) {
2384
3129
  stream.state === "deleted" ? `Deleted stream ${stream.name}; its name is free again.` : `Stream ${stream.name} is ${stream.state}; its name is free again once it is deleted.`
2385
3130
  );
2386
3131
  }
2387
- async function streamUsageCommand(deps, options = {}) {
2388
- const usage = await deps.api.listStreamUsage(
2389
- await deps.auth.apiKey(),
2390
- { limit: options.limit, cursor: options.cursor },
2391
- { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
2392
- );
2393
- printResourceUsage(deps, options.json, usage, "stream");
3132
+ async function streamUsageCommand(deps, options = {}) {
3133
+ const usage = await deps.api.listStreamUsage(
3134
+ await deps.auth.apiKey(),
3135
+ { limit: options.limit, cursor: options.cursor },
3136
+ { period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
3137
+ );
3138
+ printResourceUsage(deps, options.json, usage, "stream");
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
+ }
3217
+ async function usageShowCommand(deps, options = {}) {
3218
+ const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
3219
+ print(
3220
+ deps,
3221
+ options.json,
3222
+ usage,
3223
+ [
3224
+ `Usage in ${usage.period} (${usage.currency.toUpperCase()})`,
3225
+ usage.lines.length ? table(
3226
+ ["PRODUCT", "REGION", "DIMENSION", "MODEL", "QUANTITY", "AMOUNT"],
3227
+ usage.lines.map((line) => [
3228
+ line.product,
3229
+ line.region,
3230
+ line.dimension,
3231
+ line.model ?? "",
3232
+ line.quantity,
3233
+ usd(line.amount_micros)
3234
+ ])
3235
+ ) : "No rated usage.",
3236
+ `Total: ${usd(usage.total_micros)}`
3237
+ ].join("\n")
3238
+ );
3239
+ }
3240
+
3241
+ // src/help.ts
3242
+ import { Help } from "commander";
3243
+
3244
+ // src/machine.ts
3245
+ import { randomUUID as randomUUID4 } from "node:crypto";
3246
+
3247
+ // src/volume.ts
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
+ });
2394
3368
  }
2395
- async function usageShowCommand(deps, options = {}) {
2396
- const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
2397
- print(
2398
- deps,
2399
- options.json,
2400
- usage,
2401
- [
2402
- `Usage in ${usage.period} (${usage.currency.toUpperCase()})`,
2403
- usage.lines.length ? table(
2404
- ["PRODUCT", "REGION", "DIMENSION", "MODEL", "QUANTITY", "AMOUNT"],
2405
- usage.lines.map((line) => [
2406
- line.product,
2407
- line.region,
2408
- line.dimension,
2409
- line.model ?? "",
2410
- line.quantity,
2411
- usd(line.amount_micros)
2412
- ])
2413
- ) : "No rated usage.",
2414
- `Total: ${usd(usage.total_micros)}`
2415
- ].join("\n")
2416
- );
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 };
2417
3375
  }
2418
3376
 
2419
3377
  // src/machine.ts
2420
- import { randomUUID as randomUUID3 } from "node:crypto";
2421
3378
  var MACHINE_ERROR_HINTS = {
2422
3379
  region_not_offered: "Machines are not offered in that region yet; nothing was created or changed. Name a region where they are offered.",
2423
3380
  region_unavailable: "That region cannot be reached right now; nothing was created or changed. Try again shortly.",
@@ -2426,7 +3383,6 @@ var MACHINE_ERROR_HINTS = {
2426
3383
  hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
2427
3384
  account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
2428
3385
  image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
2429
- image_not_offered: "Only the platform image runs until image pulls ship: create or update the Machine with `--image tonbo.dev/platform`.",
2430
3386
  shape_not_offered: "`tonbo machine shapes` lists the shapes a Machine may name now.",
2431
3387
  port_reserved: "Serve the application on another port.",
2432
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.",
@@ -2434,11 +3390,13 @@ var MACHINE_ERROR_HINTS = {
2434
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.",
2435
3391
  snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
2436
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.",
2437
3394
  idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
2438
3395
  create_rate_limited: "Too many Machines were created in the last hour; try again later.",
2439
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.",
2440
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`.",
2441
- 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."
2442
3400
  };
2443
3401
  var MACHINE_IMAGE_FAILURE_HINTS = {
2444
3402
  image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
@@ -2447,9 +3405,9 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
2447
3405
  image_unsupported: "Rebuild the image without the entry the message names"
2448
3406
  };
2449
3407
  var MACHINE_CREATION_FAILURE_HINTS = {
2450
- database_creation_failed: "The Machine's database could not be created; the message names the stream and the code. If the code is payment_required, add credit to the account first; then delete the Machine and create it again.",
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.",
2451
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.",
2452
- 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.",
2453
3411
  ...Object.fromEntries(
2454
3412
  Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
2455
3413
  code,
@@ -2457,10 +3415,10 @@ var MACHINE_CREATION_FAILURE_HINTS = {
2457
3415
  ])
2458
3416
  )
2459
3417
  };
2460
- function creationFailureText(machine) {
3418
+ function creationFailureText(machine, billing) {
2461
3419
  const { failure } = machine;
2462
3420
  if (!failure) return { reason: "", hint: "" };
2463
- const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code];
3421
+ const hint = MACHINE_CREATION_FAILURE_HINTS[failure.code]?.replaceAll("<billing>", billing);
2464
3422
  return { reason: ` (${failure.code}: ${failure.message})`, hint: hint ? `
2465
3423
  ${hint}` : "" };
2466
3424
  }
@@ -2475,6 +3433,18 @@ function explainMachineError(error) {
2475
3433
  return new HttpError(`${error.message}
2476
3434
  ${hint}`, error.status, error.body, error.requestId);
2477
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
+ }
2478
3448
  async function machineCommand(run) {
2479
3449
  try {
2480
3450
  return await run();
@@ -2488,7 +3458,7 @@ function portText(port) {
2488
3458
  function machinePortsText(machine) {
2489
3459
  return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
2490
3460
  }
2491
- var { earlyReclaimSeconds, pollAfterSeconds, wakeHoldSeconds } = machineTimings;
3461
+ var { earlyReclaimSeconds, pollAfterSeconds, sleepPollAfterSeconds, wakeHoldSeconds } = machineTimings;
2492
3462
  function machineState(machine) {
2493
3463
  if (machine.failure)
2494
3464
  return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
@@ -2516,31 +3486,56 @@ function sleepText(machine) {
2516
3486
  function activationFields(machine) {
2517
3487
  const { activation } = machine;
2518
3488
  if (!activation) return [];
2519
- return [
3489
+ const fields2 = [
2520
3490
  [
2521
3491
  "Activation",
2522
3492
  `${activation.state} since ${activation.started_at} (wake ${activation.wake_gen}, from ${activation.source})`
2523
3493
  ]
2524
3494
  ];
2525
- }
2526
- function conditionText(condition) {
3495
+ if (typeof activation.revision === "number" && activation.revision !== machine.revision)
3496
+ fields2.push([
3497
+ "Pending",
3498
+ `Spec ${machine.revision}, admitted at ${activation.revision}: ports and sleep-after already apply; if the image, command, shape or env changed, run \`tonbo machine sleep ${machine.name}\` to apply it`
3499
+ ]);
3500
+ return fields2;
3501
+ }
3502
+ var MACHINE_WAKE_BLOCKED_HINTS = {
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.",
3505
+ meter_lag: "Tonbo could not confirm the account's usage in time; the next request tries again.",
3506
+ crash_backoff: "Its recent wakes failed; see `tonbo machine activations <name>`.",
3507
+ capacity: "No host had room for its shape; the next request tries again.",
3508
+ region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
3509
+ };
3510
+ function conditionText(name, condition, billing) {
2527
3511
  switch (condition.type) {
2528
- case "wake_blocked":
2529
- return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}`;
3512
+ case "wake_blocked": {
3513
+ const retry = condition.retry_at ? `, retry after ${condition.retry_at}` : "";
3514
+ const hint = MACHINE_WAKE_BLOCKED_HINTS[condition.reason]?.replaceAll("<name>", name).replaceAll("<billing>", billing);
3515
+ return `wake blocked (${condition.reason.replaceAll("_", " ")}) since ${condition.since}${retry}.${hint ? ` ${hint}` : ""}`;
3516
+ }
3517
+ case "creation_delayed":
3518
+ return `still creating since ${condition.since}, longer than usual; Tonbo's operators are alerted, and creation goes on until it is active or failed.`;
2530
3519
  case "node_unreachable":
2531
3520
  return `its host is unreachable since ${condition.since}`;
2532
3521
  case "egress_blocked":
2533
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\`.`;
2534
3525
  default:
2535
3526
  return JSON.stringify(condition);
2536
3527
  }
2537
3528
  }
2538
- function conditionFields(machine) {
3529
+ function conditionFields(machine, billing) {
2539
3530
  return (machine.status.conditions ?? []).map((condition) => [
2540
3531
  "Condition",
2541
- conditionText(condition)
3532
+ conditionText(machine.name, condition, billing)
2542
3533
  ]);
2543
3534
  }
3535
+ function endText(end) {
3536
+ const status = end.exit_code !== null ? `, exit code ${end.exit_code}` : end.signal !== null ? `, signal ${end.signal}` : "";
3537
+ return `${end.state} (${end.reason.replaceAll("_", " ")}${status}) at ${end.at}`;
3538
+ }
2544
3539
  var DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees#sleep";
2545
3540
  var PRIVATE_PORTS_LINK = "https://tonbo.dev/docs/machines/requests#private-ports";
2546
3541
  function privatePortLines(machine) {
@@ -2574,7 +3569,7 @@ function imageFailureFields(machine) {
2574
3569
  ...next ? [["Next step", next]] : []
2575
3570
  ];
2576
3571
  }
2577
- function machineFields(machine, etag = null) {
3572
+ function machineFields(machine, billing, etag = null, lastEnd = null) {
2578
3573
  return fields([
2579
3574
  ["Name", machine.name],
2580
3575
  ["ID", machine.id],
@@ -2582,7 +3577,8 @@ function machineFields(machine, etag = null) {
2582
3577
  ["State", machineState(machine)],
2583
3578
  ["Power", powerText(machine)],
2584
3579
  ...activationFields(machine),
2585
- ...conditionFields(machine),
3580
+ ...lastEnd ? [["Last end", endText(lastEnd)]] : [],
3581
+ ...conditionFields(machine, billing),
2586
3582
  ["Sleeps", sleepText(machine)],
2587
3583
  ["Shape", machine.shape],
2588
3584
  ["Image", imageText(machine)],
@@ -2595,17 +3591,23 @@ function machineFields(machine, etag = null) {
2595
3591
  ["SSH", `${machine.hostnames.ssh} (not served yet)`],
2596
3592
  ["Env", Object.keys(machine.env).sort().join(", ") || "none"],
2597
3593
  ["Stop grace", `${machine.stop_grace_seconds} s`],
3594
+ ["Volume", volumeText(machine)],
2598
3595
  ["Database", `${machine.database.name} (${machine.database.url})`],
2599
3596
  ["Logs", machine.logs.url],
2600
3597
  ["Durability", DURABILITY_LINK],
2601
3598
  ["Revision", String(machine.revision)],
2602
3599
  ...etag ? [["ETag", etag]] : [],
3600
+ ...machine.created_by ? [["Created by", machine.created_by]] : [],
2603
3601
  ["Created", machine.created_at],
2604
3602
  ["Updated", machine.updated_at]
2605
3603
  ]);
2606
3604
  }
2607
- function machineText(machine, etag = null) {
2608
- return [machineFields(machine, etag), ...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");
2609
3611
  }
2610
3612
  function keyStateText(machine) {
2611
3613
  const state = machine.byoc.key_state;
@@ -2630,8 +3632,8 @@ function deviceFields(machine) {
2630
3632
  ["Updated", machine.updated_at]
2631
3633
  ]);
2632
3634
  }
2633
- function anyMachineText(machine, etag = null) {
2634
- return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, etag);
3635
+ function anyMachineText(machine, billing, etag = null, lastEnd = null) {
3636
+ return machine.kind === "byoc" ? deviceFields(machine) : machineText(machine, billing, etag, lastEnd);
2635
3637
  }
2636
3638
  function parsePorts(specs) {
2637
3639
  const accesses = machinePortAccess;
@@ -2681,7 +3683,7 @@ function waitTimeout(options) {
2681
3683
  return options.timeout ?? DEFAULT_WAIT_SECONDS;
2682
3684
  }
2683
3685
  var DEFAULT_WAIT_SECONDS = 300;
2684
- async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
3686
+ async function waitForMachine(deps, key, name, firstWait, every, timeout, settled) {
2685
3687
  const now = deps.now ?? Date.now;
2686
3688
  const pause = deps.pause ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
2687
3689
  const start = now();
@@ -2697,7 +3699,7 @@ async function waitForMachine(deps, key, name, firstWait, timeout, settled) {
2697
3699
  if (settled(machine))
2698
3700
  return { machine, last: machine, waited: Math.round((now() - start) / 1e3) };
2699
3701
  last = machine;
2700
- wait = pollAfterSeconds;
3702
+ wait = every;
2701
3703
  }
2702
3704
  }
2703
3705
  async function machineListCommand(deps, options = {}) {
@@ -2713,7 +3715,7 @@ async function machineListCommand(deps, options = {}) {
2713
3715
  if (managed.length)
2714
3716
  tables.push(
2715
3717
  table(
2716
- ["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
3718
+ ["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS", "CONDITIONS"],
2717
3719
  managed.map((machine) => [
2718
3720
  machine.name,
2719
3721
  machine.region,
@@ -2721,7 +3723,8 @@ async function machineListCommand(deps, options = {}) {
2721
3723
  machine.status.power,
2722
3724
  machine.shape,
2723
3725
  machine.image.reference,
2724
- machine.hostnames.https
3726
+ machine.hostnames.https,
3727
+ conditionsCell(machine)
2725
3728
  ])
2726
3729
  )
2727
3730
  );
@@ -2747,6 +3750,13 @@ async function machineListCommand(deps, options = {}) {
2747
3750
  );
2748
3751
  });
2749
3752
  }
3753
+ function conditionsCell(machine) {
3754
+ const conditions = machine.status.conditions ?? [];
3755
+ if (!conditions.length) return "-";
3756
+ return conditions.map(
3757
+ (condition) => condition.type === "wake_blocked" ? `wake_blocked:${condition.reason}` : condition.type
3758
+ ).join(",");
3759
+ }
2750
3760
  function createdText(machine) {
2751
3761
  if (machine.state === "creating")
2752
3762
  return `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready, or create with --wait.`;
@@ -2783,12 +3793,14 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
2783
3793
  key,
2784
3794
  name,
2785
3795
  firstWait,
3796
+ pollAfterSeconds,
2786
3797
  timeout,
2787
3798
  (read) => read.state !== "creating"
2788
3799
  );
3800
+ const billing = await billingUrl(deps);
2789
3801
  if (!settled) {
2790
3802
  const known = last ?? machine;
2791
- print(deps, json, known, machineFields(known));
3803
+ print(deps, json, known, machineFields(known, billing));
2792
3804
  throw new Error(
2793
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.`
2794
3806
  );
@@ -2801,14 +3813,14 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
2801
3813
  settled,
2802
3814
  [
2803
3815
  `Created ${settled.name} in ${settled.region} (active after ${waited} s${pulled}).`,
2804
- machineText(settled)
3816
+ machineText(settled, billing)
2805
3817
  ].join("\n")
2806
3818
  );
2807
3819
  return;
2808
3820
  }
2809
- print(deps, json, settled, machineFields(settled));
3821
+ print(deps, json, settled, machineFields(settled, billing));
2810
3822
  if (settled.state === "failed") {
2811
- const { reason, hint } = creationFailureText(settled);
3823
+ const { reason, hint } = creationFailureText(settled, billing);
2812
3824
  throw new Error(
2813
3825
  `Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
2814
3826
  );
@@ -2822,6 +3834,8 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2822
3834
  const env = parseEnv(options.env ?? []);
2823
3835
  const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
2824
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;
2825
3839
  const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
2826
3840
  let created;
2827
3841
  try {
@@ -2831,15 +3845,16 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2831
3845
  name,
2832
3846
  image: options.image,
2833
3847
  shape,
2834
- region: options.region,
3848
+ region,
2835
3849
  command: command.length ? command : void 0,
2836
3850
  ports: ports.length ? ports : void 0,
2837
3851
  env: Object.keys(env).length ? env : void 0,
2838
3852
  sleep_after_seconds: options.sleepAfter,
2839
3853
  idle_action: options.idleAction,
2840
- stop_grace_seconds: options.stopGrace
3854
+ stop_grace_seconds: options.stopGrace,
3855
+ ...bound ? { volumes: [bound.binding] } : {}
2841
3856
  },
2842
- options.idempotencyKey ?? randomUUID3()
3857
+ options.idempotencyKey ?? randomUUID4()
2843
3858
  );
2844
3859
  } catch (error) {
2845
3860
  if (error instanceof HttpError && error.code === "name_taken")
@@ -2849,8 +3864,9 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2849
3864
  const { machine } = created;
2850
3865
  specWarnings(deps, machine.ports, Object.keys(env));
2851
3866
  if (machine.state === "failed") {
2852
- print(deps, options.json, machine, machineFields(machine));
2853
- 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);
2854
3870
  throw new Error(
2855
3871
  `Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
2856
3872
  );
@@ -2865,20 +3881,68 @@ async function machineCreateCommand(deps, name, command, options = {}) {
2865
3881
  timeout,
2866
3882
  options.json
2867
3883
  );
2868
- 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
+ );
2869
3890
  });
2870
3891
  }
2871
3892
  async function machineShowCommand(deps, name, options = {}) {
2872
3893
  await machineCommand(async () => {
2873
- const { machine, etag } = await deps.api.getMachineWithEtag(await deps.auth.apiKey(), name);
2874
- print(deps, options.json, machine, anyMachineText(machine, etag));
3894
+ const key = await deps.auth.apiKey();
3895
+ const { machine, etag } = await deps.api.getMachineWithEtag(key, name);
3896
+ const lastEnd = options.json || machine.kind === "byoc" || machine.state !== "active" ? null : await lastActivationEnd(deps, key, machine.name);
3897
+ print(
3898
+ deps,
3899
+ options.json,
3900
+ machine,
3901
+ anyMachineText(machine, await billingUrl(deps), etag, lastEnd)
3902
+ );
3903
+ });
3904
+ }
3905
+ async function lastActivationEnd(deps, key, name) {
3906
+ try {
3907
+ const page = await deps.api.listMachineActivations(key, name, { limit: 2 });
3908
+ return page.data.find((entry) => entry.end !== null)?.end ?? null;
3909
+ } catch (error) {
3910
+ if (!(error instanceof HttpError)) throw error;
3911
+ deps.notice(
3912
+ `Could not read how its last activation ended (${error.status}); \`tonbo machine activations ${name}\` lists them.`
3913
+ );
3914
+ return null;
3915
+ }
3916
+ }
3917
+ async function machineActivationsCommand(deps, name, options = {}) {
3918
+ await machineCommand(async () => {
3919
+ const page = await deps.api.listMachineActivations(await deps.auth.apiKey(), name, {
3920
+ limit: options.limit,
3921
+ cursor: options.cursor
3922
+ });
3923
+ printPage(
3924
+ deps,
3925
+ options.json,
3926
+ page,
3927
+ (activations) => activations.length ? table(
3928
+ ["WAKE", "SOURCE", "REVISION", "SHAPE", "STARTED", "END"],
3929
+ activations.map((entry) => [
3930
+ String(entry.wake_gen),
3931
+ entry.source,
3932
+ String(entry.revision),
3933
+ entry.shape,
3934
+ entry.started_at,
3935
+ entry.end ? endText(entry.end) : "live"
3936
+ ])
3937
+ ) : `Machine ${name} has not been woken yet.`
3938
+ );
2875
3939
  });
2876
3940
  }
2877
3941
  function appliesText(name, change) {
2878
3942
  const lines = [];
2879
3943
  if (change.ports !== void 0 || change.sleep_after_seconds !== void 0)
2880
3944
  lines.push("Ports and sleep-after apply now.");
2881
- 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(
2882
3946
  (field) => field !== void 0
2883
3947
  );
2884
3948
  if (later) {
@@ -2926,6 +3990,9 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
2926
3990
  for (const variable of options.unsetEnv ?? []) delete env[variable];
2927
3991
  change.env = env;
2928
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];
2929
3996
  if (options.discardSnapshot) change.discard_snapshot = true;
2930
3997
  if (Object.values(change).every((value) => value === void 0))
2931
3998
  throw new Error("Nothing to change: pass at least one option or a command.");
@@ -2938,7 +4005,7 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
2938
4005
  [
2939
4006
  `Updated Machine ${machine.name} to revision ${machine.revision}.`,
2940
4007
  ...appliesText(machine.name, change),
2941
- machineText(machine)
4008
+ machineText(machine, await billingUrl(deps))
2942
4009
  ].join("\n")
2943
4010
  );
2944
4011
  });
@@ -2954,7 +4021,11 @@ async function machineSleepCommand(deps, name, options = {}) {
2954
4021
  await machineCommand(async () => {
2955
4022
  const timeout = waitTimeout(options);
2956
4023
  const key = await deps.auth.apiKey();
2957
- const machine = await deps.api.sleepMachine(key, name, ifMatchHeader(options.ifMatch));
4024
+ const { machine, retryAfterSeconds: retryAfterSeconds2 } = await deps.api.sleepMachine(
4025
+ key,
4026
+ name,
4027
+ ifMatchHeader(options.ifMatch)
4028
+ );
2958
4029
  const going = options.wait && machine.state === "active" && machine.status.power !== "asleep";
2959
4030
  if (!going) {
2960
4031
  print(deps, options.json, machine, sleptText(machine));
@@ -2964,7 +4035,8 @@ async function machineSleepCommand(deps, name, options = {}) {
2964
4035
  deps,
2965
4036
  key,
2966
4037
  name,
2967
- pollAfterSeconds,
4038
+ retryAfterSeconds2 ?? sleepPollAfterSeconds,
4039
+ sleepPollAfterSeconds,
2968
4040
  timeout,
2969
4041
  (read) => read.status.power === "asleep" || read.state !== "active"
2970
4042
  );
@@ -3053,6 +4125,528 @@ async function machineShapesCommand(deps, options = {}) {
3053
4125
  });
3054
4126
  }
3055
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
+
3056
4650
  // src/prompt.ts
3057
4651
  import select from "@inquirer/select";
3058
4652
  import { createInterface } from "node:readline/promises";
@@ -3113,7 +4707,7 @@ function operationOptions(command, operationId) {
3113
4707
  command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
3114
4708
  return command.option("--json", "print the API data as JSON");
3115
4709
  }
3116
- var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes); the ETag the command reads, if it reads one, by default`;
4710
+ var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes)`;
3117
4711
  var repeated = (value, previous = []) => [...previous, value];
3118
4712
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
3119
4713
  function machineSpecOptions(command, update) {
@@ -3124,7 +4718,7 @@ function machineSpecOptions(command, update) {
3124
4718
  repeated
3125
4719
  ).option(
3126
4720
  "--default-port <port>",
3127
- `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",
3128
4722
  parseWhole
3129
4723
  ).option(
3130
4724
  "--env <KEY=VALUE>",
@@ -3142,6 +4736,9 @@ function machineSpecOptions(command, update) {
3142
4736
  "--stop-grace <seconds>",
3143
4737
  "the seconds between SIGTERM and SIGKILL on a planned stop",
3144
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" : ""}`
3145
4742
  );
3146
4743
  }
3147
4744
  function parseExpiresIn(value) {
@@ -3160,7 +4757,12 @@ function parseUsageSort(value) {
3160
4757
  }
3161
4758
  function createProgram(dependencies = createDependencies) {
3162
4759
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
3163
- 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) => loginCommand(dependencies(), 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) => {
4764
+ await loginCommand(dependencies(), options);
4765
+ });
3164
4766
  program.command("logout").description("revoke this computer's key and remove the saved login").action(async () => logoutCommand(dependencies()));
3165
4767
  const account = program.command("account").description("the account you are logged in to");
3166
4768
  operationOptions(
@@ -3193,7 +4795,7 @@ function createProgram(dependencies = createDependencies) {
3193
4795
  async (subject, options) => memberUpdateCommand(dependencies(), subject, options)
3194
4796
  );
3195
4797
  operationOptions(
3196
- 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(
3197
4799
  "--region <region>",
3198
4800
  "permanent home region (see tonbo region list); required once several regions accept new agents"
3199
4801
  ).option(
@@ -3212,10 +4814,7 @@ function createProgram(dependencies = createDependencies) {
3212
4814
  async (options) => apiKeyListCommand(dependencies(), options)
3213
4815
  );
3214
4816
  operationOptions(
3215
- apiKey.command("create <name>").description("create a key and print its secret, once").option(
3216
- "--member <member>",
3217
- "an Agent (subject, id or name) to create the key for; yourself by default"
3218
- ).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(
3219
4818
  "--bucket <name>",
3220
4819
  "narrow the key to this bucket, for its whole life; repeat for several",
3221
4820
  repeated
@@ -3236,9 +4835,7 @@ function createProgram(dependencies = createDependencies) {
3236
4835
  "narrow the key to the raw streams whose name starts with this, for its whole life; repeat for several; never with --bucket",
3237
4836
  repeated
3238
4837
  ).addOption(
3239
- new Option("--expires-in <period>", "when the key expires; never by default").choices(
3240
- apiKeyExpirations
3241
- )
4838
+ new Option("--expires-in <period>", "when the key expires").choices(apiKeyExpirations)
3242
4839
  ),
3243
4840
  "createApiKey"
3244
4841
  ).action(
@@ -3258,10 +4855,7 @@ function createProgram(dependencies = createDependencies) {
3258
4855
  async (options) => bucketListCommand(dependencies(), options)
3259
4856
  );
3260
4857
  operationOptions(
3261
- bucket.command("create <name>").description("create a bucket").option(
3262
- "--region <region>",
3263
- "the bucket's region (see tonbo region list); the account's default region by default"
3264
- ).option(
4858
+ bucket.command("create <name>").description("create a bucket").option("--region <region>", "the bucket's region (see tonbo region list)").option(
3265
4859
  "--idempotency-key <key>",
3266
4860
  "reuse the key of an unconfirmed creation to get the bucket it created"
3267
4861
  ),
@@ -3292,18 +4886,15 @@ function createProgram(dependencies = createDependencies) {
3292
4886
  async (options) => databaseListCommand(dependencies(), options)
3293
4887
  );
3294
4888
  operationOptions(
3295
- database.command("create <name>").description("create a database; its region is fixed for its life").option(
3296
- "--region <region>",
3297
- "the database's region (see tonbo region list); the database default region by default"
3298
- ).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(
3299
4890
  new Option(
3300
4891
  "--expires-at <time>",
3301
- "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"
3302
4893
  ).conflicts("expiresIn")
3303
4894
  ).addOption(
3304
4895
  new Option(
3305
4896
  "--expires-in <duration>",
3306
- "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"
3307
4898
  ).argParser(parseExpiresIn).conflicts("expiresAt")
3308
4899
  ).option(
3309
4900
  "--idempotency-key <key>",
@@ -3332,10 +4923,15 @@ function createProgram(dependencies = createDependencies) {
3332
4923
  async (name, options) => databaseDeleteCommand(dependencies(), name, options)
3333
4924
  );
3334
4925
  operationOptions(
3335
- database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option(
3336
- "--period <month>",
3337
- "the calendar month in UTC, as YYYY-MM; the current one by default"
3338
- ).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"),
3339
4935
  "listDatabaseUsage"
3340
4936
  ).action(
3341
4937
  async (options) => databaseUsageCommand(dependencies(), options)
@@ -3348,21 +4944,18 @@ function createProgram(dependencies = createDependencies) {
3348
4944
  async (options) => streamListCommand(dependencies(), options)
3349
4945
  );
3350
4946
  operationOptions(
3351
- stream.command("create <name>").description("create a stream; its region and content type are fixed for its life").option(
3352
- "--region <region>",
3353
- "the stream's region (see tonbo region list); the database default region by default"
3354
- ).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(
3355
4948
  "--content-type <type>",
3356
- "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"
3357
4950
  ).addOption(
3358
4951
  new Option(
3359
4952
  "--expires-at <time>",
3360
- "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"
3361
4954
  ).conflicts("expiresIn")
3362
4955
  ).addOption(
3363
4956
  new Option(
3364
4957
  "--expires-in <duration>",
3365
- "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"
3366
4959
  ).argParser(parseExpiresIn).conflicts("expiresAt")
3367
4960
  ).option(
3368
4961
  "--idempotency-key <key>",
@@ -3391,10 +4984,15 @@ function createProgram(dependencies = createDependencies) {
3391
4984
  async (name, options) => streamDeleteCommand(dependencies(), name, options)
3392
4985
  );
3393
4986
  operationOptions(
3394
- stream.command("usage").description("each stream's usage of one month at list price, before allowance and credit").option(
3395
- "--period <month>",
3396
- "the calendar month in UTC, as YYYY-MM; the current one by default"
3397
- ).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"),
3398
4996
  "listStreamUsage"
3399
4997
  ).action(
3400
4998
  async (options) => streamUsageCommand(dependencies(), options)
@@ -3412,20 +5010,13 @@ function createProgram(dependencies = createDependencies) {
3412
5010
  "create a Machine from an image; [command...] after -- replaces the image's CMD"
3413
5011
  ).requiredOption(
3414
5012
  "--image <reference>",
3415
- "the image the Machine boots: tonbo.dev/platform, or a public linux/arm64 OCI image once registry images are offered"
3416
- ).option(
3417
- "--region <region>",
3418
- "the Machine's region, fixed for its life; the account's default region by default"
3419
- ),
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"),
3420
5015
  false
3421
5016
  ).option(
3422
5017
  "--idempotency-key <key>",
3423
5018
  "reuse the key of an unconfirmed creation to get the Machine it created"
3424
- ).option("--wait", "wait until the Machine is active, or say why it failed").option(
3425
- "--timeout <seconds>",
3426
- `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3427
- parseWhole
3428
- ),
5019
+ ).option("--wait", "wait until the Machine is active, or say why it failed").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
3429
5020
  "createMachine"
3430
5021
  ).action(
3431
5022
  async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
@@ -3440,7 +5031,7 @@ function createProgram(dependencies = createDependencies) {
3440
5031
  machineSpecOptions(
3441
5032
  machine.command("update <name> [command...]").description(
3442
5033
  "change a Machine; [command...] after -- replaces its command at the next boot"
3443
- ).option("--image <reference>", "a new image, named as for create; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option("--if-match <etag>", ifMatchHelp("change")).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
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"),
3444
5035
  true
3445
5036
  ),
3446
5037
  "updateMachine"
@@ -3448,11 +5039,7 @@ function createProgram(dependencies = createDependencies) {
3448
5039
  async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
3449
5040
  );
3450
5041
  operationOptions(
3451
- machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option(
3452
- "--timeout <seconds>",
3453
- `how long --wait waits; ${DEFAULT_WAIT_SECONDS} by default`,
3454
- parseWhole
3455
- ),
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),
3456
5043
  "sleepMachine"
3457
5044
  ).action(
3458
5045
  async (name, options) => machineSleepCommand(dependencies(), name, options)
@@ -3465,20 +5052,62 @@ function createProgram(dependencies = createDependencies) {
3465
5052
  ).action(
3466
5053
  async (name, options) => machineDeleteCommand(dependencies(), name, options)
3467
5054
  );
5055
+ operationOptions(
5056
+ machine.command("activations <name>").description("list a Machine's activations, newest first, and how each ended"),
5057
+ "listMachineActivations"
5058
+ ).action(
5059
+ async (name, options) => machineActivationsCommand(dependencies(), name, options)
5060
+ );
3468
5061
  operationOptions(
3469
5062
  machine.command("shapes").description("list the shapes a Machine may have"),
3470
5063
  "listMachineShapes"
3471
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");
5066
+ operationOptions(
5067
+ volume.command("list").description("list the Volumes in every region"),
5068
+ "listVolumes"
5069
+ ).action(async (options) => volumeListCommand(dependencies(), options));
3472
5070
  operationOptions(
3473
- program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
3474
- "--period <month>",
3475
- "the calendar month in UTC, as YYYY-MM; the current one by default"
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"
3476
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"),
3477
5094
  "getUsage"
3478
5095
  ).action(
3479
5096
  async (options) => usageShowCommand(dependencies(), options)
3480
5097
  );
3481
- 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);
3482
5111
  }
3483
5112
 
3484
5113
  // src/main.ts