@tonbo/cli 0.5.0 → 0.6.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 +29 -7
  2. package/dist/bin/tonbo.js +1064 -524
  3. package/package.json +7 -7
package/dist/bin/tonbo.js CHANGED
@@ -4,8 +4,150 @@
4
4
  import { Command, InvalidArgumentError, Option } from "commander";
5
5
  import { readFileSync } from "node:fs";
6
6
 
7
- // src/http.ts
7
+ // ../../packages/tonbo-cli-core/dist/src/auth.js
8
+ import { createHash, randomBytes } from "node:crypto";
9
+ import { createServer } from "node:http";
10
+ import { execFile } from "node:child_process";
11
+ import os2 from "node:os";
12
+ import { promisify } from "node:util";
13
+
14
+ // ../../packages/tonbo-cli-core/dist/src/callback-page.js
15
+ var WORDMARK = [
16
+ "\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
17
+ "\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
18
+ "\u2588\u2588\u2588\u2584\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2584\u2588\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2588\u2584\u2584\u2588"
19
+ ].join("\n");
20
+ var OUTCOMES = {
21
+ complete: {
22
+ status: 200,
23
+ title: "You are signed in.",
24
+ body: "Return to your terminal. This tab can be closed."
25
+ },
26
+ denied: {
27
+ status: 400,
28
+ title: "Login was not completed.",
29
+ body: "Nothing was authorized. Return to your terminal to try again."
30
+ },
31
+ invalid: {
32
+ // The state did not match, the code was missing, or the path was wrong.
33
+ // Say that it could not be verified rather than which check failed: the
34
+ // person who can read this page is not necessarily the one who started
35
+ // the login.
36
+ status: 400,
37
+ title: "This callback could not be verified.",
38
+ body: "Return to your terminal and start the login again."
39
+ }
40
+ };
41
+ function renderCallbackPage(outcome) {
42
+ const { title, body } = OUTCOMES[outcome];
43
+ return `<!doctype html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width, initial-scale=1">
48
+ <meta name="robots" content="noindex">
49
+ <title>Tonbo CLI</title>
50
+ <style>
51
+ :root {
52
+ --paper: #f3f2ee;
53
+ --ink: #1d1b17;
54
+ --rule: #c9c5ba;
55
+ --quiet: #5c564a;
56
+ --signal: #d64a03;
57
+ }
58
+ @media (prefers-color-scheme: dark) {
59
+ :root {
60
+ --paper: #1d1b17;
61
+ --ink: #f3f2ee;
62
+ --rule: #3a362e;
63
+ --quiet: #a49e8f;
64
+ --signal: #f08b4b;
65
+ }
66
+ }
67
+ * { box-sizing: border-box; }
68
+ body {
69
+ margin: 0;
70
+ min-height: 100vh;
71
+ display: grid;
72
+ grid-template-rows: auto 1fr;
73
+ padding: 0 clamp(20px, 5vw, 72px);
74
+ background: var(--paper);
75
+ color: var(--ink);
76
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
77
+ -webkit-font-smoothing: antialiased;
78
+ }
79
+ header { padding: 28px 0; }
80
+ /* Same metrics as .auth-wordmark in the app: the glyphs are the ground
81
+ and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
82
+ what makes the rows meet without seams. Iosevka is not available here,
83
+ so it falls back to the platform monospace. */
84
+ pre.wordmark {
85
+ margin: 0;
86
+ font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
87
+ font-size: 8px;
88
+ font-weight: 400;
89
+ line-height: 10px;
90
+ letter-spacing: 0;
91
+ white-space: pre;
92
+ color: var(--signal);
93
+ transform: scaleY(0.8);
94
+ transform-origin: left top;
95
+ }
96
+ main {
97
+ display: flex;
98
+ flex-direction: column;
99
+ justify-content: center;
100
+ max-width: 46ch;
101
+ padding-bottom: 12vh;
102
+ }
103
+ h1 {
104
+ margin: 0;
105
+ font-size: 26px;
106
+ font-weight: 300;
107
+ line-height: 1.16;
108
+ letter-spacing: -0.018em;
109
+ }
110
+ p {
111
+ margin: 14px 0 0;
112
+ font-size: 16px;
113
+ line-height: 1.55;
114
+ color: var(--quiet);
115
+ }
116
+ hr {
117
+ width: 40px;
118
+ margin: 28px 0 0;
119
+ border: 0;
120
+ border-top: 1px solid var(--rule);
121
+ }
122
+ </style>
123
+ </head>
124
+ <body>
125
+ <header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
126
+ <main>
127
+ <h1>${title}</h1>
128
+ <p>${body}</p>
129
+ <hr>
130
+ </main>
131
+ </body>
132
+ </html>
133
+ `;
134
+ }
135
+ function callbackResponse(outcome) {
136
+ return {
137
+ status: OUTCOMES[outcome].status,
138
+ headers: {
139
+ "content-type": "text/html; charset=utf-8",
140
+ "cache-control": "no-store"
141
+ },
142
+ body: renderCallbackPage(outcome)
143
+ };
144
+ }
145
+
146
+ // ../../packages/tonbo-cli-core/dist/src/http.js
8
147
  var HttpError = class extends Error {
148
+ status;
149
+ body;
150
+ requestId;
9
151
  constructor(message, status, body, requestId) {
10
152
  super(message);
11
153
  this.status = status;
@@ -22,26 +164,26 @@ var HttpError = class extends Error {
22
164
  async function requestJson(fetcher, url, init = {}) {
23
165
  const response = await fetcher(url, init);
24
166
  const body = await response.json().catch(() => null);
25
- if (response.ok) return body;
167
+ if (response.ok)
168
+ return body;
26
169
  const record = body && typeof body === "object" ? body : {};
27
- const reason = [record.detail, record.title, record.error].find(
28
- (value) => typeof value === "string" && value.length > 0
29
- );
170
+ const reason = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
30
171
  const requestId = response.headers.get("x-request-id") ?? (typeof record.request_id === "string" ? record.request_id : null);
31
172
  const issues = Array.isArray(record.errors) ? record.errors.filter((issue) => typeof issue?.field === "string" && typeof issue.code === "string").map((issue) => `${issue.field}: ${issue.code}`) : [];
32
173
  const message = [
33
174
  reason ?? `Request failed with HTTP ${response.status}.`,
34
175
  ...issues.length ? [`(${issues.join(", ")})`] : []
35
176
  ].join(" ");
36
- throw new HttpError(
37
- requestId ? `${message} (request ${requestId})` : message,
38
- response.status,
39
- body,
40
- requestId
41
- );
177
+ throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
42
178
  }
43
179
 
44
- // src/generated/contracts.ts
180
+ // ../../packages/tonbo-cli-core/dist/src/credentials.js
181
+ import { randomUUID } from "node:crypto";
182
+ import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
183
+ import os from "node:os";
184
+ import path from "node:path";
185
+
186
+ // ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
45
187
  var agentNameContract = {
46
188
  "$schema": "https://json-schema.org/draft/2020-12/schema",
47
189
  "$id": "https://contracts.tonbo.dev/agents/agent-name-v1.json",
@@ -343,6 +485,70 @@ var managementOperations = {
343
485
  ],
344
486
  "idempotencyKey": false,
345
487
  "cli": "tonbo usage show"
488
+ },
489
+ "listDatabases": {
490
+ "method": "GET",
491
+ "path": "/databases",
492
+ "pathParams": [],
493
+ "query": [
494
+ "prefix",
495
+ "cursor",
496
+ "limit"
497
+ ],
498
+ "idempotencyKey": false,
499
+ "cli": "tonbo database list"
500
+ },
501
+ "createDatabase": {
502
+ "method": "POST",
503
+ "path": "/databases",
504
+ "pathParams": [],
505
+ "query": [],
506
+ "idempotencyKey": true,
507
+ "cli": "tonbo database create"
508
+ },
509
+ "getDatabase": {
510
+ "method": "GET",
511
+ "path": "/databases/{name}",
512
+ "pathParams": [
513
+ "name"
514
+ ],
515
+ "query": [],
516
+ "idempotencyKey": false,
517
+ "cli": "tonbo database show"
518
+ },
519
+ "updateDatabase": {
520
+ "method": "PATCH",
521
+ "path": "/databases/{name}",
522
+ "pathParams": [
523
+ "name"
524
+ ],
525
+ "query": [],
526
+ "idempotencyKey": false,
527
+ "cli": "tonbo database update"
528
+ },
529
+ "deleteDatabase": {
530
+ "method": "DELETE",
531
+ "path": "/databases/{name}",
532
+ "pathParams": [
533
+ "name"
534
+ ],
535
+ "query": [],
536
+ "idempotencyKey": false,
537
+ "cli": "tonbo database delete"
538
+ },
539
+ "listDatabaseUsage": {
540
+ "method": "GET",
541
+ "path": "/usage/databases",
542
+ "pathParams": [],
543
+ "query": [
544
+ "period",
545
+ "sort",
546
+ "prefix",
547
+ "cursor",
548
+ "limit"
549
+ ],
550
+ "idempotencyKey": false,
551
+ "cli": "tonbo database usage"
346
552
  }
347
553
  };
348
554
  var accountRoles = [
@@ -357,298 +563,305 @@ var apiKeyExpirations = [
357
563
  "1_year",
358
564
  "never"
359
565
  ];
360
-
361
- // src/api.ts
362
- var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
363
- var ManagementApi = class {
364
- constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN) {
365
- this.fetcher = fetcher;
366
- this.apiOrigin = apiOrigin2;
367
- }
368
- /** The answer's `data`. A refusal throws an HttpError naming the request id. */
369
- async call(operationId, key, request = {}) {
370
- return (await this.request(operationId, key, request)).data;
566
+ var credentialsContract = {
567
+ "version": 1,
568
+ "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`.",
569
+ "path": {
570
+ "description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
571
+ "base_env": "XDG_CONFIG_HOME",
572
+ "fallback_base": [
573
+ "$HOME",
574
+ ".config"
575
+ ],
576
+ "relative": [
577
+ "tonbo",
578
+ "credentials.json"
579
+ ]
580
+ },
581
+ "permissions": {
582
+ "description": "POSIX only; a reader on Windows skips the mode checks.",
583
+ "directory_mode": "0700",
584
+ "file_mode": "0600",
585
+ "writer": "The writer creates the directory 0700, writes a temporary file 0600 beside the target, syncs it and renames it over the target, so a crash never leaves a partial key. It refuses to replace or remove a symlink or anything that is not a regular file."
586
+ },
587
+ "key_sources": {
588
+ "description": "Where a client takes its key, first match wins. A library takes an explicit option first; the CLIs have none.",
589
+ "order": [
590
+ "option",
591
+ "env",
592
+ "file"
593
+ ],
594
+ "env": "TONBO_API_KEY"
595
+ },
596
+ "reader": {
597
+ "missing": "No file is not logged in: ask the person to run `tonbo login`.",
598
+ "refuse_symlink": true,
599
+ "refuse_non_regular_file": true,
600
+ "refuse_mode_bits": "0077",
601
+ "refused": "A symlink, a non-regular file, or a file any group or other permission bit is set on is an error and is never read; do not fall back to another source.",
602
+ "unknown_version": "A `version` other than `current_version`, or content that does not match `schema`, is unusable: ask the person to run `tonbo login`. It is never migrated.",
603
+ "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.",
604
+ "login_hint": "tonbo login"
605
+ },
606
+ "current_version": 1,
607
+ "schema": {
608
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
609
+ "$id": "https://tonbo.dev/contracts/cli/credentials-v1.json",
610
+ "title": "Tonbo CLI login",
611
+ "type": "object",
612
+ "required": [
613
+ "version",
614
+ "account_id",
615
+ "key_id",
616
+ "key",
617
+ "expires_at"
618
+ ],
619
+ "additionalProperties": true,
620
+ "properties": {
621
+ "version": {
622
+ "const": 1
623
+ },
624
+ "account_id": {
625
+ "type": "string",
626
+ "minLength": 1,
627
+ "description": "The account the key belongs to (a UUID)."
628
+ },
629
+ "key_id": {
630
+ "type": "string",
631
+ "minLength": 1,
632
+ "description": "The key's id, its `client_id` at the exchange (a UUID)."
633
+ },
634
+ "key": {
635
+ "type": "string",
636
+ "pattern": "^tbo_",
637
+ "description": "The personal key's secret (contracts/iam/access-token-v1.json `personal_key`)."
638
+ },
639
+ "expires_at": {
640
+ "type": [
641
+ "string",
642
+ "null"
643
+ ],
644
+ "format": "date-time",
645
+ "description": "When the key expires, ISO 8601; null for a key that never expires."
646
+ }
647
+ }
648
+ },
649
+ "examples": {
650
+ "usable": [
651
+ {
652
+ "version": 1,
653
+ "account_id": "10000000-0000-4000-8000-000000000001",
654
+ "key_id": "20000000-0000-4000-8000-000000000002",
655
+ "key": "tbo_secret",
656
+ "expires_at": "2027-04-24T00:00:00.000Z"
657
+ },
658
+ {
659
+ "version": 1,
660
+ "account_id": "10000000-0000-4000-8000-000000000001",
661
+ "key_id": "20000000-0000-4000-8000-000000000002",
662
+ "key": "tbo_secret",
663
+ "expires_at": null
664
+ }
665
+ ],
666
+ "unusable": [
667
+ {
668
+ "access_token": "access",
669
+ "refresh_token": "refresh",
670
+ "token_type": "bearer"
671
+ },
672
+ {
673
+ "version": 2,
674
+ "account_id": "10000000-0000-4000-8000-000000000001",
675
+ "key_id": "20000000-0000-4000-8000-000000000002",
676
+ "key": "tbo_secret",
677
+ "expires_at": null
678
+ },
679
+ {
680
+ "version": 1,
681
+ "account_id": "10000000-0000-4000-8000-000000000001",
682
+ "key_id": "20000000-0000-4000-8000-000000000002",
683
+ "key": "not-a-personal-key",
684
+ "expires_at": null
685
+ },
686
+ {
687
+ "version": 1,
688
+ "account_id": "",
689
+ "key_id": "20000000-0000-4000-8000-000000000002",
690
+ "key": "tbo_secret",
691
+ "expires_at": null
692
+ },
693
+ {
694
+ "version": 1,
695
+ "account_id": "10000000-0000-4000-8000-000000000001",
696
+ "key_id": "20000000-0000-4000-8000-000000000002",
697
+ "key": "tbo_secret",
698
+ "expires_at": "not a date"
699
+ },
700
+ {
701
+ "version": 1,
702
+ "account_id": "10000000-0000-4000-8000-000000000001",
703
+ "key_id": "20000000-0000-4000-8000-000000000002",
704
+ "key": "tbo_secret"
705
+ }
706
+ ]
371
707
  }
372
- /** One page of a list operation: its `data` and `next_cursor`. */
373
- page(operationId, key, { limit, cursor } = {}) {
374
- return this.request(operationId, key, { query: { limit, cursor } });
708
+ };
709
+
710
+ // ../../packages/tonbo-cli-core/dist/src/credentials.js
711
+ var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
712
+ var FILE_MODE = Number.parseInt(credentialsContract.permissions.file_mode, 8);
713
+ var REFUSED_MODE_BITS = Number.parseInt(credentialsContract.reader.refuse_mode_bits, 8);
714
+ function defaultCredentialsPath(env = process.env) {
715
+ const rule = credentialsContract.path;
716
+ const base = env[rule.base_env] || path.join(...rule.fallback_base.map((part) => part === "$HOME" ? os.homedir() : part));
717
+ return path.join(base, ...rule.relative);
718
+ }
719
+ function isExpiredLogin(login, now = Date.now()) {
720
+ return login.expires_at !== null && Date.parse(login.expires_at) <= now;
721
+ }
722
+ var UnusableLoginError = class extends Error {
723
+ };
724
+ var FileCredentialStore = class {
725
+ filename;
726
+ loginHint;
727
+ constructor(filename = defaultCredentialsPath(), loginHint = credentialsContract.reader.login_hint) {
728
+ this.filename = filename;
729
+ this.loginHint = loginHint;
375
730
  }
376
- async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
377
- const operation = managementOperations[operationId];
378
- const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
379
- const value = params[name];
380
- if (value === void 0) throw new Error(`${operationId} needs the path parameter ${name}.`);
381
- return encodeURIComponent(value);
382
- });
383
- const search = new URLSearchParams();
384
- for (const [name, value] of Object.entries(query)) {
385
- if (value === void 0) continue;
386
- if (!operation.query.includes(name))
387
- throw new Error(`${operationId} takes no query parameter ${name}.`);
388
- search.set(name, String(value));
389
- }
390
- if (operation.idempotencyKey && !idempotencyKey)
391
- throw new Error(`${operationId} needs an Idempotency-Key.`);
392
- const headers = { authorization: `Bearer ${key}` };
393
- if (body !== void 0) headers["content-type"] = "application/json";
394
- if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
395
- const queryString = search.toString();
396
- const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
731
+ async load() {
732
+ let parsed;
397
733
  try {
398
- return await requestJson(this.fetcher, url, {
399
- method: operation.method,
400
- headers,
401
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
402
- });
734
+ await assertPrivateRegularFile(this.filename);
735
+ parsed = JSON.parse(await readFile(this.filename, "utf8"));
403
736
  } catch (error) {
404
- if (error instanceof HttpError && error.status === 401)
405
- throw new HttpError(
406
- `${error.message} Run \`tonbo login\`.`,
407
- error.status,
408
- error.body,
409
- error.requestId
410
- );
411
- throw error;
737
+ if (error.code === "ENOENT")
738
+ return null;
739
+ throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
412
740
  }
741
+ if (!isStoredLogin(parsed))
742
+ throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
743
+ return parsed;
413
744
  }
414
- getAccount(key) {
415
- return this.call("getAccount", key);
416
- }
417
- listRegions(key) {
418
- return this.call("listRegions", key);
419
- }
420
- listMembers(key, page) {
421
- return this.page("listMembers", key, page);
422
- }
423
- updateMember(key, subject, change) {
424
- return this.call("updateMember", key, { params: { subject }, body: change });
425
- }
426
- /** A replay with the same idempotency key returns the Agent the first
427
- * request created, so an unconfirmed creation is retried with that key. */
428
- createAgent(key, request, idempotencyKey) {
429
- return this.call("createAgent", key, {
430
- body: withoutUndefined(request),
431
- idempotencyKey
432
- }).catch(unconfirmed("Agent creation", idempotencyKey, "the same name, role and region"));
433
- }
434
- listApiKeys(key, page) {
435
- return this.page("listApiKeys", key, page);
436
- }
437
- /** Not idempotent: the secret is never stored, so a replay could not
438
- * return it, and a blind retry of a live name answers 409. */
439
- createApiKey(key, request) {
440
- return this.call("createApiKey", key, { body: withoutUndefined(request) });
441
- }
442
- getApiKey(key, keyId) {
443
- return this.call("getApiKey", key, { params: { key_id: keyId } });
444
- }
445
- revokeApiKey(key, keyId) {
446
- return this.call("revokeApiKey", key, {
447
- params: { key_id: keyId }
448
- });
449
- }
450
- listBuckets(key, page) {
451
- return this.page("listBuckets", key, page);
452
- }
453
- /** A replay with the same idempotency key returns the bucket the first
454
- * request created. */
455
- createBucket(key, request, idempotencyKey) {
456
- return this.call("createBucket", key, {
457
- body: withoutUndefined(request),
458
- idempotencyKey
459
- }).catch(unconfirmed("Bucket creation", idempotencyKey, "the same name and region"));
460
- }
461
- getBucket(key, name) {
462
- return this.call("getBucket", key, { params: { name } });
463
- }
464
- updateBucket(key, name, change) {
465
- return this.call("updateBucket", key, { params: { name }, body: change });
466
- }
467
- deleteBucket(key, name) {
468
- return this.call("deleteBucket", key, { params: { name } });
745
+ async save(login) {
746
+ if (!isStoredLogin(login))
747
+ throw new Error("Refusing to store a malformed Tonbo login.");
748
+ const directory = path.dirname(this.filename);
749
+ await mkdir(directory, { mode: DIRECTORY_MODE, recursive: true });
750
+ await preparePrivateDirectory(directory);
751
+ await assertExistingDestinationIsSafe(this.filename);
752
+ const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
753
+ let handle = null;
754
+ try {
755
+ handle = await open(temporary, "wx", FILE_MODE);
756
+ await handle.writeFile(`${JSON.stringify(login, null, 2)}
757
+ `, "utf8");
758
+ await handle.sync();
759
+ await handle.close();
760
+ handle = null;
761
+ if (process.platform !== "win32")
762
+ await chmod(temporary, FILE_MODE);
763
+ await rename(temporary, this.filename);
764
+ } catch (error) {
765
+ await handle?.close().catch(() => void 0);
766
+ await rm(temporary, { force: true }).catch(() => void 0);
767
+ throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
768
+ }
469
769
  }
470
- getUsage(key, period) {
471
- return this.call("getUsage", key, { query: { period } });
770
+ async remove() {
771
+ await assertExistingDestinationIsSafe(this.filename);
772
+ await rm(this.filename, { force: true });
472
773
  }
473
774
  };
474
- function withoutUndefined(value) {
475
- return Object.fromEntries(
476
- Object.entries(value).filter(([, field]) => field !== void 0)
477
- );
775
+ var RULE_KEYWORDS = /* @__PURE__ */ new Set(["const", "type", "minLength", "pattern", "format", "description"]);
776
+ function matchesRule(value, rule) {
777
+ if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
778
+ return false;
779
+ if ("const" in rule)
780
+ return value === rule.const;
781
+ const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
782
+ if (value === null)
783
+ return types.includes("null");
784
+ if (typeof value !== "string" || !types.includes("string"))
785
+ return false;
786
+ if (rule.minLength !== void 0 && value.length < rule.minLength)
787
+ return false;
788
+ if (rule.pattern !== void 0 && !new RegExp(rule.pattern, "u").test(value))
789
+ return false;
790
+ if (rule.format === "date-time" && Number.isNaN(Date.parse(value)))
791
+ return false;
792
+ return true;
478
793
  }
479
- function unconfirmed(what, idempotencyKey, same) {
480
- return (cause) => {
481
- if (cause instanceof HttpError && cause.status < 500) throw cause;
482
- throw new Error(
483
- `${what} was not confirmed: ${cause instanceof Error ? cause.message : String(cause)} Retry with --idempotency-key ${idempotencyKey} and ${same}.`,
484
- { cause }
485
- );
486
- };
794
+ function isStoredLogin(value) {
795
+ if (!value || typeof value !== "object" || Array.isArray(value))
796
+ return false;
797
+ const login = value;
798
+ const schema = credentialsContract.schema;
799
+ if (login.version !== credentialsContract.current_version)
800
+ return false;
801
+ if (!schema.required.every((name) => Object.hasOwn(login, name)))
802
+ return false;
803
+ const properties = schema.properties;
804
+ return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
487
805
  }
488
-
489
- // src/auth.ts
490
- import { createHash, randomBytes } from "node:crypto";
491
- import { createServer } from "node:http";
492
- import { execFile } from "node:child_process";
493
- import os from "node:os";
494
- import { promisify } from "node:util";
495
-
496
- // src/callback-page.ts
497
- var WORDMARK = [
498
- "\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
499
- "\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
500
- "\u2588\u2588\u2588\u2584\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2584\u2588\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2588\u2584\u2584\u2588"
501
- ].join("\n");
502
- var OUTCOMES = {
503
- complete: {
504
- status: 200,
505
- title: "You are signed in.",
506
- body: "Return to your terminal. This tab can be closed."
507
- },
508
- denied: {
509
- status: 400,
510
- title: "Login was not completed.",
511
- body: "Nothing was authorized. Return to your terminal to try again."
512
- },
513
- invalid: {
514
- // The state did not match, the code was missing, or the path was wrong.
515
- // Say that it could not be verified rather than which check failed: the
516
- // person who can read this page is not necessarily the one who started
517
- // the login.
518
- status: 400,
519
- title: "This callback could not be verified.",
520
- body: "Return to your terminal and start the login again."
521
- }
522
- };
523
- function renderCallbackPage(outcome) {
524
- const { title, body } = OUTCOMES[outcome];
525
- return `<!doctype html>
526
- <html lang="en">
527
- <head>
528
- <meta charset="utf-8">
529
- <meta name="viewport" content="width=device-width, initial-scale=1">
530
- <meta name="robots" content="noindex">
531
- <title>Tonbo CLI</title>
532
- <style>
533
- :root {
534
- --paper: #f3f2ee;
535
- --ink: #1d1b17;
536
- --rule: #c9c5ba;
537
- --quiet: #5c564a;
538
- --signal: #d64a03;
539
- }
540
- @media (prefers-color-scheme: dark) {
541
- :root {
542
- --paper: #1d1b17;
543
- --ink: #f3f2ee;
544
- --rule: #3a362e;
545
- --quiet: #a49e8f;
546
- --signal: #f08b4b;
547
- }
548
- }
549
- * { box-sizing: border-box; }
550
- body {
551
- margin: 0;
552
- min-height: 100vh;
553
- display: grid;
554
- grid-template-rows: auto 1fr;
555
- padding: 0 clamp(20px, 5vw, 72px);
556
- background: var(--paper);
557
- color: var(--ink);
558
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
559
- -webkit-font-smoothing: antialiased;
560
- }
561
- header { padding: 28px 0; }
562
- /* Same metrics as .auth-wordmark in the app: the glyphs are the ground
563
- and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
564
- what makes the rows meet without seams. Iosevka is not available here,
565
- so it falls back to the platform monospace. */
566
- pre.wordmark {
567
- margin: 0;
568
- font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
569
- font-size: 8px;
570
- font-weight: 400;
571
- line-height: 10px;
572
- letter-spacing: 0;
573
- white-space: pre;
574
- color: var(--signal);
575
- transform: scaleY(0.8);
576
- transform-origin: left top;
577
- }
578
- main {
579
- display: flex;
580
- flex-direction: column;
581
- justify-content: center;
582
- max-width: 46ch;
583
- padding-bottom: 12vh;
584
- }
585
- h1 {
586
- margin: 0;
587
- font-size: 26px;
588
- font-weight: 300;
589
- line-height: 1.16;
590
- letter-spacing: -0.018em;
591
- }
592
- p {
593
- margin: 14px 0 0;
594
- font-size: 16px;
595
- line-height: 1.55;
596
- color: var(--quiet);
597
- }
598
- hr {
599
- width: 40px;
600
- margin: 28px 0 0;
601
- border: 0;
602
- border-top: 1px solid var(--rule);
806
+ async function preparePrivateDirectory(directory) {
807
+ const stat = await lstat(directory);
808
+ if (stat.isSymbolicLink() || !stat.isDirectory())
809
+ throw new Error("Tonbo config directory must be a real directory.");
810
+ if (process.platform !== "win32")
811
+ await chmod(directory, DIRECTORY_MODE);
812
+ }
813
+ async function assertExistingDestinationIsSafe(filename) {
814
+ try {
815
+ const stat = await lstat(filename);
816
+ if (stat.isSymbolicLink() || !stat.isFile())
817
+ throw new Error("Tonbo credential path must be a regular file.");
818
+ } catch (error) {
819
+ if (error.code !== "ENOENT")
820
+ throw error;
603
821
  }
604
- </style>
605
- </head>
606
- <body>
607
- <header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
608
- <main>
609
- <h1>${title}</h1>
610
- <p>${body}</p>
611
- <hr>
612
- </main>
613
- </body>
614
- </html>
615
- `;
616
822
  }
617
- function callbackResponse(outcome) {
618
- return {
619
- status: OUTCOMES[outcome].status,
620
- headers: {
621
- "content-type": "text/html; charset=utf-8",
622
- "cache-control": "no-store"
623
- },
624
- body: renderCallbackPage(outcome)
625
- };
823
+ async function assertPrivateRegularFile(filename) {
824
+ const stat = await lstat(filename);
825
+ if (stat.isSymbolicLink() || !stat.isFile())
826
+ throw new Error("Tonbo credential path must be a regular file.");
827
+ if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
828
+ throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
626
829
  }
627
830
 
628
- // src/auth.ts
831
+ // ../../packages/tonbo-cli-core/dist/src/auth.js
629
832
  var exec = promisify(execFile);
833
+ var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
630
834
  var LOOPBACK_HOST = "127.0.0.1";
631
- function loginHostname(hostname = os.hostname()) {
835
+ function loginHostname(hostname = os2.hostname()) {
632
836
  const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
633
837
  return cleaned || "computer";
634
838
  }
635
839
  var AuthClient = class {
636
- constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname()) {
840
+ credentials;
841
+ fetcher;
842
+ loginOrigin;
843
+ browserOpener;
844
+ hostname;
845
+ loginHint;
846
+ constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
637
847
  this.credentials = credentials;
638
848
  this.fetcher = fetcher;
639
849
  this.loginOrigin = loginOrigin2;
640
850
  this.browserOpener = browserOpener;
641
851
  this.hostname = hostname;
852
+ this.loginHint = loginHint;
642
853
  }
643
854
  /** The personal key requests are made with: TONBO_API_KEY, or the saved
644
855
  * login while it has not expired. */
645
856
  async apiKey() {
646
857
  const injected = process.env.TONBO_API_KEY?.trim();
647
- if (injected) return injected;
858
+ if (injected)
859
+ return injected;
648
860
  const login = await this.credentials.load();
649
- if (!login) throw new Error("Not logged in. Run `tonbo login`.");
650
- if (login.expires_at && Date.parse(login.expires_at) <= Date.now())
651
- throw new Error("Your Tonbo login has expired. Run `tonbo login`.");
861
+ if (!login)
862
+ throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
863
+ if (isExpiredLogin(login))
864
+ throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
652
865
  return login.key;
653
866
  }
654
867
  /**
@@ -661,32 +874,24 @@ var AuthClient = class {
661
874
  const verifier = randomBytes(48).toString("base64url");
662
875
  const challenge = createHash("sha256").update(verifier).digest("base64url");
663
876
  const state = randomBytes(24).toString("base64url");
664
- const code = await this.listenForCode(
665
- state,
666
- (redirectUri) => {
667
- const authorization = new URL("/cli/login", this.loginOrigin);
668
- authorization.search = new URLSearchParams({
669
- code_challenge: challenge,
670
- code_challenge_method: "S256",
671
- redirect_uri: redirectUri,
672
- state,
673
- hostname: this.hostname,
674
- ...account ? { account } : {}
675
- }).toString();
676
- return this.browserOpener(authorization.toString());
677
- },
678
- progress
679
- );
877
+ const code = await this.listenForCode(state, (redirectUri) => {
878
+ const authorization = new URL("/cli/login", this.loginOrigin);
879
+ authorization.search = new URLSearchParams({
880
+ code_challenge: challenge,
881
+ code_challenge_method: "S256",
882
+ redirect_uri: redirectUri,
883
+ state,
884
+ hostname: this.hostname,
885
+ ...account ? { account } : {}
886
+ }).toString();
887
+ return this.browserOpener(authorization.toString());
888
+ }, progress);
680
889
  progress({ status: "started", step: "key-redemption" });
681
- const redeemed = await requestJson(
682
- this.fetcher,
683
- new URL("/api/cli/login/token", this.loginOrigin).toString(),
684
- {
685
- method: "POST",
686
- headers: { "content-type": "application/json" },
687
- body: JSON.stringify({ code, code_verifier: verifier })
688
- }
689
- );
890
+ const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
891
+ method: "POST",
892
+ headers: { "content-type": "application/json" },
893
+ body: JSON.stringify({ code, code_verifier: verifier })
894
+ });
690
895
  progress({ status: "completed", step: "key-redemption" });
691
896
  const login = {
692
897
  version: 1,
@@ -706,39 +911,42 @@ var AuthClient = class {
706
911
  const sockets = /* @__PURE__ */ new Set();
707
912
  let authorizationStarted = false;
708
913
  const startAuthorization = () => {
709
- if (authorizationStarted) return;
914
+ if (authorizationStarted)
915
+ return;
710
916
  authorizationStarted = true;
711
917
  progress({ status: "completed", step: "browser" });
712
918
  progress({ status: "started", step: "browser-authorization" });
713
919
  };
714
920
  const finish = (result, completedResponseSocket) => {
715
- if (settled) return;
921
+ if (settled)
922
+ return;
716
923
  settled = true;
717
924
  clearTimeout(timeout);
718
925
  const complete = (closeError) => {
719
- if (closeError) reject(closeError);
926
+ if (closeError)
927
+ reject(closeError);
720
928
  else if ("code" in result) {
721
929
  progress({ status: "completed", step: "callback-close" });
722
930
  resolve(result.code);
723
- } else reject(result.error);
931
+ } else
932
+ reject(result.error);
724
933
  };
725
- if ("code" in result) progress({ status: "started", step: "callback-close" });
934
+ if ("code" in result)
935
+ progress({ status: "started", step: "callback-close" });
726
936
  if (!server.listening) {
727
937
  complete();
728
938
  return;
729
939
  }
730
940
  server.close(complete);
731
941
  for (const socket of sockets) {
732
- if (socket !== completedResponseSocket) socket.destroy();
942
+ if (socket !== completedResponseSocket)
943
+ socket.destroy();
733
944
  }
734
945
  server.closeIdleConnections();
735
946
  };
736
- const timeout = setTimeout(
737
- () => {
738
- finish({ error: new Error("Timed out waiting for browser login.") });
739
- },
740
- 5 * 60 * 1e3
741
- );
947
+ const timeout = setTimeout(() => {
948
+ finish({ error: new Error("Timed out waiting for browser login.") });
949
+ }, 5 * 60 * 1e3);
742
950
  const server = createServer((request, response) => {
743
951
  const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
744
952
  if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
@@ -748,11 +956,7 @@ var AuthClient = class {
748
956
  const denied = url.searchParams.get("error");
749
957
  if (denied) {
750
958
  const responseSocket2 = response.socket;
751
- respond(
752
- response,
753
- "denied",
754
- () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2)
755
- );
959
+ respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
756
960
  return;
757
961
  }
758
962
  const code = url.searchParams.get("code");
@@ -775,141 +979,184 @@ var AuthClient = class {
775
979
  const { port } = server.address();
776
980
  progress({ status: "completed", step: "callback-server" });
777
981
  progress({ status: "started", step: "browser" });
778
- void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch(
779
- (error) => finish({
780
- error: error instanceof Error ? error : new Error("Could not open the login browser.")
781
- })
782
- );
982
+ void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
983
+ error: error instanceof Error ? error : new Error("Could not open the login browser.")
984
+ }));
783
985
  });
784
986
  });
785
987
  }
786
988
  };
787
989
  function respond(response, outcome, finished) {
788
990
  const page = callbackResponse(outcome);
789
- if (finished) response.once("finish", finished);
991
+ if (finished)
992
+ response.once("finish", finished);
790
993
  response.writeHead(page.status, { ...page.headers, connection: "close" });
791
994
  response.end(page.body);
792
995
  }
793
996
  async function openBrowser(url) {
794
- if (process.platform === "darwin") return void await exec("open", [url]);
997
+ if (process.platform === "darwin")
998
+ return void await exec("open", [url]);
795
999
  if (process.platform === "win32")
796
1000
  return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
797
1001
  return void await exec("xdg-open", [url]);
798
1002
  }
799
1003
 
800
- // src/commands.ts
801
- import { randomUUID as randomUUID2 } from "node:crypto";
802
-
803
- // src/credentials.ts
804
- import { randomUUID } from "node:crypto";
805
- import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
806
- import os2 from "node:os";
807
- import path from "node:path";
808
- function defaultConfigDirectory() {
809
- const base = process.env.XDG_CONFIG_HOME || path.join(os2.homedir(), ".config");
810
- return path.join(base, "tonbo");
811
- }
812
- var UnusableLoginError = class extends Error {
813
- };
814
- var FileCredentialStore = class {
815
- constructor(filename = path.join(defaultConfigDirectory(), "credentials.json")) {
816
- this.filename = filename;
1004
+ // ../../packages/tonbo-cli-core/dist/src/api.js
1005
+ var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
1006
+ var ManagementApi = class {
1007
+ fetcher;
1008
+ apiOrigin;
1009
+ loginHint;
1010
+ constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT) {
1011
+ this.fetcher = fetcher;
1012
+ this.apiOrigin = apiOrigin2;
1013
+ this.loginHint = loginHint;
817
1014
  }
818
- async load() {
819
- let parsed;
820
- try {
821
- await assertPrivateRegularFile(this.filename);
822
- parsed = JSON.parse(await readFile(this.filename, "utf8"));
823
- } catch (error) {
824
- if (error.code === "ENOENT") return null;
825
- throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
826
- }
827
- if (!isStoredLogin(parsed))
828
- throw new UnusableLoginError(
829
- `The saved login at ${this.filename} is not usable by this CLI. Run \`tonbo login\`.`
830
- );
831
- return parsed;
1015
+ /** The answer's `data`. A refusal throws an HttpError naming the request id. */
1016
+ async call(operationId, key, request = {}) {
1017
+ return (await this.request(operationId, key, request)).data;
832
1018
  }
833
- async save(login) {
834
- if (!isStoredLogin(login)) throw new Error("Refusing to store a malformed Tonbo login.");
835
- const directory = path.dirname(this.filename);
836
- await mkdir(directory, { mode: 448, recursive: true });
837
- await preparePrivateDirectory(directory);
838
- await assertExistingDestinationIsSafe(this.filename);
839
- const temporary = path.join(
840
- directory,
841
- `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`
842
- );
843
- let handle = null;
1019
+ /** One page of a list operation: its `data` and `next_cursor`, with the
1020
+ * operation's other query parameters, if any. */
1021
+ page(operationId, key, { limit, cursor } = {}, query = {}) {
1022
+ return this.request(operationId, key, { query: { ...query, limit, cursor } });
1023
+ }
1024
+ async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
1025
+ const operation = managementOperations[operationId];
1026
+ const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
1027
+ const value = params[name];
1028
+ if (value === void 0)
1029
+ throw new Error(`${operationId} needs the path parameter ${name}.`);
1030
+ return encodeURIComponent(value);
1031
+ });
1032
+ const search = new URLSearchParams();
1033
+ for (const [name, value] of Object.entries(query)) {
1034
+ if (value === void 0)
1035
+ continue;
1036
+ if (!operation.query.includes(name))
1037
+ throw new Error(`${operationId} takes no query parameter ${name}.`);
1038
+ search.set(name, String(value));
1039
+ }
1040
+ if (operation.idempotencyKey && !idempotencyKey)
1041
+ throw new Error(`${operationId} needs an Idempotency-Key.`);
1042
+ const headers = { authorization: `Bearer ${key}` };
1043
+ if (body !== void 0)
1044
+ headers["content-type"] = "application/json";
1045
+ if (idempotencyKey)
1046
+ headers["idempotency-key"] = idempotencyKey;
1047
+ const queryString = search.toString();
1048
+ const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
844
1049
  try {
845
- handle = await open(temporary, "wx", 384);
846
- await handle.writeFile(`${JSON.stringify(login, null, 2)}
847
- `, "utf8");
848
- await handle.sync();
849
- await handle.close();
850
- handle = null;
851
- if (process.platform !== "win32") await chmod(temporary, 384);
852
- await rename(temporary, this.filename);
1050
+ return await requestJson(this.fetcher, url, {
1051
+ method: operation.method,
1052
+ headers,
1053
+ ...body === void 0 ? {} : { body: JSON.stringify(body) }
1054
+ });
853
1055
  } catch (error) {
854
- await handle?.close().catch(() => void 0);
855
- await rm(temporary, { force: true }).catch(() => void 0);
856
- throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
1056
+ if (error instanceof HttpError && error.status === 401)
1057
+ throw new HttpError(`${error.message} Run \`${this.loginHint}\`.`, error.status, error.body, error.requestId);
1058
+ throw error;
857
1059
  }
858
1060
  }
859
- async remove() {
860
- await assertExistingDestinationIsSafe(this.filename);
861
- await rm(this.filename, { force: true });
1061
+ getAccount(key) {
1062
+ return this.call("getAccount", key);
862
1063
  }
863
- };
864
- function isStoredLogin(value) {
865
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
866
- const login = value;
867
- return login.version === 1 && typeof login.account_id === "string" && login.account_id.length > 0 && typeof login.key_id === "string" && login.key_id.length > 0 && typeof login.key === "string" && login.key.startsWith("tbo_") && (login.expires_at === null || typeof login.expires_at === "string" && !Number.isNaN(Date.parse(login.expires_at)));
868
- }
869
- async function preparePrivateDirectory(directory) {
870
- const stat = await lstat(directory);
871
- if (stat.isSymbolicLink() || !stat.isDirectory())
872
- throw new Error("Tonbo config directory must be a real directory.");
873
- if (process.platform !== "win32") await chmod(directory, 448);
874
- }
875
- async function assertExistingDestinationIsSafe(filename) {
876
- try {
877
- const stat = await lstat(filename);
878
- if (stat.isSymbolicLink() || !stat.isFile())
879
- throw new Error("Tonbo credential path must be a regular file.");
880
- } catch (error) {
881
- if (error.code !== "ENOENT") throw error;
1064
+ listRegions(key) {
1065
+ return this.call("listRegions", key);
882
1066
  }
883
- }
884
- async function assertPrivateRegularFile(filename) {
885
- const stat = await lstat(filename);
886
- if (stat.isSymbolicLink() || !stat.isFile())
887
- throw new Error("Tonbo credential path must be a regular file.");
888
- if (process.platform !== "win32" && (stat.mode & 63) !== 0)
889
- throw new Error("Tonbo credential file must have mode 0600.");
890
- }
891
-
892
- // src/agent-name.ts
893
- var AGENT_NAME_RULE = agentNameContract.$defs.name;
894
- var AGENT_NAME_PATTERN = new RegExp(AGENT_NAME_RULE.pattern);
895
- var RESERVED_AGENT_NAMES = new Set(AGENT_NAME_RULE.not.enum);
896
- function validateAgentName(name) {
897
- const valid = name.length >= AGENT_NAME_RULE.minLength && name.length <= AGENT_NAME_RULE.maxLength && AGENT_NAME_PATTERN.test(name) && !RESERVED_AGENT_NAMES.has(name);
898
- if (!valid) {
899
- throw new Error(
900
- `Agent name must be ${AGENT_NAME_RULE.minLength}-${AGENT_NAME_RULE.maxLength} lowercase letters, digits, or single hyphens, start with a letter, and not be reserved.`
901
- );
1067
+ listMembers(key, page) {
1068
+ return this.page("listMembers", key, page);
1069
+ }
1070
+ updateMember(key, subject, change) {
1071
+ return this.call("updateMember", key, { params: { subject }, body: change });
1072
+ }
1073
+ /** A replay with the same idempotency key returns the Agent the first
1074
+ * request created, so an unconfirmed creation is retried with that key. */
1075
+ createAgent(key, request, idempotencyKey) {
1076
+ return this.call("createAgent", key, {
1077
+ body: withoutUndefined(request),
1078
+ idempotencyKey
1079
+ }).catch(unconfirmed("Agent creation", idempotencyKey, "the same name, role and region"));
1080
+ }
1081
+ listApiKeys(key, page) {
1082
+ return this.page("listApiKeys", key, page);
1083
+ }
1084
+ /** Not idempotent: the secret is never stored, so a replay could not
1085
+ * return it, and a blind retry of a live name answers 409. */
1086
+ createApiKey(key, request) {
1087
+ return this.call("createApiKey", key, { body: withoutUndefined(request) });
1088
+ }
1089
+ getApiKey(key, keyId) {
1090
+ return this.call("getApiKey", key, { params: { key_id: keyId } });
1091
+ }
1092
+ revokeApiKey(key, keyId) {
1093
+ return this.call("revokeApiKey", key, {
1094
+ params: { key_id: keyId }
1095
+ });
1096
+ }
1097
+ listBuckets(key, page) {
1098
+ return this.page("listBuckets", key, page);
1099
+ }
1100
+ /** A replay with the same idempotency key returns the bucket the first
1101
+ * request created. */
1102
+ createBucket(key, request, idempotencyKey) {
1103
+ return this.call("createBucket", key, {
1104
+ body: withoutUndefined(request),
1105
+ idempotencyKey
1106
+ }).catch(unconfirmed("Bucket creation", idempotencyKey, "the same name and region"));
1107
+ }
1108
+ getBucket(key, name) {
1109
+ return this.call("getBucket", key, { params: { name } });
1110
+ }
1111
+ updateBucket(key, name, change) {
1112
+ return this.call("updateBucket", key, { params: { name }, body: change });
1113
+ }
1114
+ deleteBucket(key, name) {
1115
+ return this.call("deleteBucket", key, { params: { name } });
1116
+ }
1117
+ listDatabases(key, page, prefix) {
1118
+ return this.page("listDatabases", key, page, { prefix });
1119
+ }
1120
+ /** A replay with the same idempotency key returns the database the first
1121
+ * request created. */
1122
+ createDatabase(key, request, idempotencyKey) {
1123
+ return this.call("createDatabase", key, {
1124
+ body: withoutUndefined(request),
1125
+ idempotencyKey
1126
+ }).catch(unconfirmed("Database creation", idempotencyKey, "the same name and region"));
1127
+ }
1128
+ getDatabase(key, name) {
1129
+ return this.call("getDatabase", key, { params: { name } });
1130
+ }
1131
+ updateDatabase(key, name, change) {
1132
+ return this.call("updateDatabase", key, { params: { name }, body: change });
1133
+ }
1134
+ deleteDatabase(key, name) {
1135
+ return this.call("deleteDatabase", key, { params: { name } });
1136
+ }
1137
+ /** The whole usage page: its rows, `other`, prices and checkpoint. */
1138
+ listDatabaseUsage(key, page, query = {}) {
1139
+ return this.page("listDatabaseUsage", key, page, query);
1140
+ }
1141
+ getUsage(key, period) {
1142
+ return this.call("getUsage", key, { query: { period } });
902
1143
  }
1144
+ };
1145
+ function withoutUndefined(value) {
1146
+ return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
1147
+ }
1148
+ function unconfirmed(what, idempotencyKey, same) {
1149
+ return (cause) => {
1150
+ if (cause instanceof HttpError && cause.status < 500)
1151
+ throw cause;
1152
+ throw new Error(`${what} was not confirmed: ${cause instanceof Error ? cause.message : String(cause)} Retry with --idempotency-key ${idempotencyKey} and ${same}.`, { cause });
1153
+ };
903
1154
  }
904
1155
 
905
- // src/format.ts
1156
+ // ../../packages/tonbo-cli-core/dist/src/format.js
906
1157
  function table(headings, rows) {
907
- const widths = headings.map(
908
- (heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length))
909
- );
910
- return [headings, ...rows].map(
911
- (row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column])).join(" ").trimEnd()
912
- ).join("\n");
1158
+ const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
1159
+ return [headings, ...rows].map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
913
1160
  }
914
1161
  function fields(pairs) {
915
1162
  const width = Math.max(...pairs.map(([label]) => label.length + 1));
@@ -924,34 +1171,31 @@ var USD = new Intl.NumberFormat("en-US", {
924
1171
  function usd(micros) {
925
1172
  return USD.format(micros / 1e6);
926
1173
  }
927
- function when(timestamp, absent = "never") {
928
- return timestamp ?? absent;
929
- }
930
-
931
- // src/commands.ts
932
- function print(deps, json, data, human) {
933
- deps.output(json ? JSON.stringify(data) : human);
934
- }
935
- function printPage(deps, json, page, human) {
936
- print(deps, json, page.data, human(page.data));
937
- if (page.next_cursor) deps.notice(`More results: run again with --cursor ${page.next_cursor}`);
938
- }
939
- async function confirm(deps, yes, action, question, expected) {
940
- if (yes) return;
941
- if (!deps.interactive())
942
- throw new Error(`Refusing to ${action} without confirmation: pass --yes.`);
943
- if (await deps.ask(await question()) !== expected)
944
- throw new Error("Cancelled; nothing was changed.");
1174
+ var BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"];
1175
+ function bytes(count, absent = "unknown") {
1176
+ if (count === null)
1177
+ return absent;
1178
+ if (count < 1024)
1179
+ return `${count} B`;
1180
+ let value = count;
1181
+ let unit = "B";
1182
+ for (const next of BYTE_UNITS) {
1183
+ if (value < 1024)
1184
+ break;
1185
+ value /= 1024;
1186
+ unit = next;
1187
+ }
1188
+ return `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${unit}`;
945
1189
  }
1190
+ function when(timestamp, absent = "never") {
1191
+ return timestamp ?? absent;
1192
+ }
1193
+
1194
+ // ../../packages/tonbo-cli-core/dist/src/login.js
946
1195
  async function loginCommand(deps, options = {}) {
947
1196
  try {
948
- const login = await deps.auth.login(
949
- (event) => reportLoginProgress(deps.progress, event),
950
- options.account
951
- );
952
- deps.output(
953
- `Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`
954
- );
1197
+ const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account);
1198
+ deps.output(`Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
955
1199
  } catch (error) {
956
1200
  deps.progress.fail();
957
1201
  throw error;
@@ -985,15 +1229,18 @@ var LOGIN_PROGRESS_MESSAGES = {
985
1229
  };
986
1230
  function reportLoginProgress(progress, event) {
987
1231
  const messages = LOGIN_PROGRESS_MESSAGES[event.step];
988
- if (event.status === "started") progress.start(messages.started);
989
- else progress.succeed(messages.completed);
1232
+ if (event.status === "started")
1233
+ progress.start(messages.started);
1234
+ else
1235
+ progress.succeed(messages.completed);
990
1236
  }
991
1237
  async function logoutCommand(deps) {
992
1238
  let login;
993
1239
  try {
994
1240
  login = await deps.credentials.load();
995
1241
  } catch (error) {
996
- if (!(error instanceof UnusableLoginError)) throw error;
1242
+ if (!(error instanceof UnusableLoginError))
1243
+ throw error;
997
1244
  await deps.credentials.remove();
998
1245
  deps.output("Removed a saved login this CLI cannot use; it had no key to revoke.");
999
1246
  return;
@@ -1011,11 +1258,114 @@ async function logoutCommand(deps) {
1011
1258
  }
1012
1259
  await deps.credentials.remove();
1013
1260
  if (failure)
1261
+ 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 });
1262
+ deps.output("Logged out. The CLI key is revoked.");
1263
+ }
1264
+
1265
+ // ../../packages/tonbo-cli-core/dist/src/origin.js
1266
+ function apiOrigin(configured = process.env.TONBO_API_ORIGIN) {
1267
+ const url = new URL(configured?.trim() || DEFAULT_API_ORIGIN);
1268
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
1269
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
1270
+ throw new Error("TONBO_API_ORIGIN must be an https origin.");
1271
+ return url.origin;
1272
+ }
1273
+ function loginOrigin(api) {
1274
+ const url = new URL(api);
1275
+ if (url.hostname.startsWith("api."))
1276
+ url.hostname = url.hostname.slice("api.".length);
1277
+ return url.origin;
1278
+ }
1279
+
1280
+ // ../../packages/tonbo-cli-core/dist/src/progress.js
1281
+ var FRAMES = ["|", "/", "-", "\\"];
1282
+ var TerminalProgress = class {
1283
+ stream;
1284
+ intervalMs;
1285
+ activeMessage;
1286
+ frame = 0;
1287
+ lastWidth = 0;
1288
+ timer;
1289
+ constructor(stream, intervalMs = 80) {
1290
+ this.stream = stream;
1291
+ this.intervalMs = intervalMs;
1292
+ }
1293
+ start(message) {
1294
+ if (this.activeMessage)
1295
+ this.succeed();
1296
+ this.activeMessage = message;
1297
+ this.frame = 0;
1298
+ if (!this.stream.isTTY) {
1299
+ this.stream.write(`[..] ${message}
1300
+ `);
1301
+ return;
1302
+ }
1303
+ this.render(`[${FRAMES[this.frame]}] ${message}`);
1304
+ this.timer = setInterval(() => {
1305
+ this.frame = (this.frame + 1) % FRAMES.length;
1306
+ this.render(`[${FRAMES[this.frame]}] ${this.activeMessage}`);
1307
+ }, this.intervalMs);
1308
+ this.timer.unref();
1309
+ }
1310
+ succeed(message = this.activeMessage) {
1311
+ this.finish("ok", message);
1312
+ }
1313
+ fail(message = this.activeMessage) {
1314
+ this.finish("!!", message);
1315
+ }
1316
+ finish(marker, message) {
1317
+ if (this.timer)
1318
+ clearInterval(this.timer);
1319
+ this.timer = void 0;
1320
+ this.activeMessage = void 0;
1321
+ if (!message)
1322
+ return;
1323
+ const line = `[${marker}] ${message}`;
1324
+ if (this.stream.isTTY) {
1325
+ this.render(line);
1326
+ this.stream.write("\n");
1327
+ this.lastWidth = 0;
1328
+ return;
1329
+ }
1330
+ this.stream.write(`${line}
1331
+ `);
1332
+ }
1333
+ render(value) {
1334
+ this.stream.write(`\r${value.padEnd(this.lastWidth)}`);
1335
+ this.lastWidth = value.length;
1336
+ }
1337
+ };
1338
+
1339
+ // src/commands.ts
1340
+ import { randomUUID as randomUUID2 } from "node:crypto";
1341
+
1342
+ // src/agent-name.ts
1343
+ var AGENT_NAME_RULE = agentNameContract.$defs.name;
1344
+ var AGENT_NAME_PATTERN = new RegExp(AGENT_NAME_RULE.pattern);
1345
+ var RESERVED_AGENT_NAMES = new Set(AGENT_NAME_RULE.not.enum);
1346
+ function validateAgentName(name) {
1347
+ const valid = name.length >= AGENT_NAME_RULE.minLength && name.length <= AGENT_NAME_RULE.maxLength && AGENT_NAME_PATTERN.test(name) && !RESERVED_AGENT_NAMES.has(name);
1348
+ if (!valid) {
1014
1349
  throw new Error(
1015
- `Removed the saved login, but could not revoke its key ${login.key_id}: ${failure.message} Revoke it under API keys in the Tonbo console.`,
1016
- { cause: failure }
1350
+ `Agent name must be ${AGENT_NAME_RULE.minLength}-${AGENT_NAME_RULE.maxLength} lowercase letters, digits, or single hyphens, start with a letter, and not be reserved.`
1017
1351
  );
1018
- deps.output("Logged out. The CLI key is revoked.");
1352
+ }
1353
+ }
1354
+
1355
+ // src/commands.ts
1356
+ function print(deps, json, data, human) {
1357
+ deps.output(json ? JSON.stringify(data) : human);
1358
+ }
1359
+ function printPage(deps, json, page, human) {
1360
+ print(deps, json, page.data, human(page.data));
1361
+ if (page.next_cursor) deps.notice(`More results: run again with --cursor ${page.next_cursor}`);
1362
+ }
1363
+ async function confirm(deps, yes, action, question, expected) {
1364
+ if (yes) return;
1365
+ if (!deps.interactive())
1366
+ throw new Error(`Refusing to ${action} without confirmation: pass --yes.`);
1367
+ if (await deps.ask(await question()) !== expected)
1368
+ throw new Error("Cancelled; nothing was changed.");
1019
1369
  }
1020
1370
  async function regionListCommand(deps, options = {}) {
1021
1371
  const regions = await deps.api.listRegions(await deps.auth.apiKey());
@@ -1024,8 +1374,8 @@ async function regionListCommand(deps, options = {}) {
1024
1374
  options.json,
1025
1375
  regions,
1026
1376
  regions.length ? table(
1027
- ["REGION", "URL"],
1028
- regions.map((region) => [region.id, region.url])
1377
+ ["REGION", "URL", "DATABASE DEFAULT"],
1378
+ regions.map((region) => [region.id, region.url, region.default ? "yes" : ""])
1029
1379
  ) : "No region accepts new resources."
1030
1380
  );
1031
1381
  }
@@ -1145,8 +1495,19 @@ async function memberUpdateCommand(deps, member, options = {}) {
1145
1495
  function keyAccess(apiKey) {
1146
1496
  return apiKey.read_only ? "read-only" : "read-write";
1147
1497
  }
1498
+ function narrowedByDatabase(apiKey) {
1499
+ return apiKey.databases.length + apiKey.database_prefixes.length > 0;
1500
+ }
1148
1501
  function keyBuckets(apiKey) {
1149
- return apiKey.buckets.length ? apiKey.buckets.join(", ") : "all";
1502
+ if (apiKey.buckets.length) return apiKey.buckets.join(", ");
1503
+ return narrowedByDatabase(apiKey) ? "none" : "all";
1504
+ }
1505
+ function keyDatabases(apiKey) {
1506
+ if (narrowedByDatabase(apiKey))
1507
+ return [...apiKey.databases, ...apiKey.database_prefixes.map((prefix) => `${prefix}*`)].join(
1508
+ ", "
1509
+ );
1510
+ return apiKey.buckets.length ? "none" : "all";
1150
1511
  }
1151
1512
  function apiKeyFields(apiKey) {
1152
1513
  return fields([
@@ -1156,6 +1517,7 @@ function apiKeyFields(apiKey) {
1156
1517
  ["Ends in", apiKey.last4],
1157
1518
  ["Access", keyAccess(apiKey)],
1158
1519
  ["Buckets", keyBuckets(apiKey)],
1520
+ ["Databases", keyDatabases(apiKey)],
1159
1521
  ["Expires", when(apiKey.expires_at)],
1160
1522
  ["Last used", when(apiKey.last_used_at)],
1161
1523
  ["Created", apiKey.created_at]
@@ -1171,7 +1533,17 @@ async function apiKeyListCommand(deps, options = {}) {
1171
1533
  options.json,
1172
1534
  page,
1173
1535
  (keys) => keys.length ? table(
1174
- ["ID", "NAME", "MEMBER", "ENDS IN", "ACCESS", "BUCKETS", "EXPIRES", "LAST USED"],
1536
+ [
1537
+ "ID",
1538
+ "NAME",
1539
+ "MEMBER",
1540
+ "ENDS IN",
1541
+ "ACCESS",
1542
+ "BUCKETS",
1543
+ "DATABASES",
1544
+ "EXPIRES",
1545
+ "LAST USED"
1546
+ ],
1175
1547
  keys.map((apiKey) => [
1176
1548
  apiKey.id,
1177
1549
  apiKey.name,
@@ -1179,6 +1551,7 @@ async function apiKeyListCommand(deps, options = {}) {
1179
1551
  apiKey.last4,
1180
1552
  keyAccess(apiKey),
1181
1553
  keyBuckets(apiKey),
1554
+ keyDatabases(apiKey),
1182
1555
  when(apiKey.expires_at),
1183
1556
  when(apiKey.last_used_at)
1184
1557
  ])
@@ -1193,6 +1566,8 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
1193
1566
  member,
1194
1567
  read_only: options.readOnly,
1195
1568
  buckets: options.bucket,
1569
+ databases: options.database,
1570
+ database_prefixes: options.databasePrefix,
1196
1571
  expires_in: options.expiresIn
1197
1572
  });
1198
1573
  print(
@@ -1204,12 +1579,20 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
1204
1579
  "",
1205
1580
  ` ${created.key}`,
1206
1581
  "",
1582
+ "For your application's environment (.env or secret store):",
1583
+ "",
1584
+ ` TONBO_API_KEY=${created.key}`,
1585
+ "",
1207
1586
  apiKeyFields(created)
1208
1587
  ].join("\n")
1209
1588
  );
1210
1589
  deps.notice(
1211
1590
  "This is the only time the key's secret is shown: store it now. Anyone holding it acts as its member."
1212
1591
  );
1592
+ if (!options.json)
1593
+ deps.notice(
1594
+ "Do not set TONBO_API_KEY in your own shell: it overrides your `tonbo login`, for this CLI too."
1595
+ );
1213
1596
  }
1214
1597
  async function apiKeyShowCommand(deps, keyId, options = {}) {
1215
1598
  const apiKey = await deps.api.getApiKey(await deps.auth.apiKey(), keyId);
@@ -1312,6 +1695,164 @@ async function bucketDeleteCommand(deps, name, options = {}) {
1312
1695
  `Bucket ${bucket.name} is ${bucket.state}; its streams are being purged.`
1313
1696
  );
1314
1697
  }
1698
+ function databaseAccess(database) {
1699
+ return database.public_read ? "public" : "private";
1700
+ }
1701
+ function databaseSize(database) {
1702
+ const { log, snapshot } = database.stored_bytes;
1703
+ if (log === null && snapshot === null) return "unknown";
1704
+ return `${bytes(log)} log, ${bytes(snapshot, "no")} snapshot`;
1705
+ }
1706
+ function databaseOwner(database) {
1707
+ return database.owner_id ? `${database.kind} ${database.owner_id}` : database.kind;
1708
+ }
1709
+ function databaseFields(database) {
1710
+ return fields([
1711
+ ["Name", database.name],
1712
+ ["Kind", databaseOwner(database)],
1713
+ ["Region", database.region],
1714
+ ["State", database.state],
1715
+ ["Read access", databaseAccess(database)],
1716
+ ["URL", database.url],
1717
+ ["Incarnation", database.incarnation ?? "none yet"],
1718
+ ["Last write", when(database.last_write_at)],
1719
+ ["Stored", databaseSize(database)],
1720
+ ["Created", `${database.created_at} by ${database.created_by}`]
1721
+ ]);
1722
+ }
1723
+ function noticeUnavailable(deps, regions, what) {
1724
+ if (regions.length)
1725
+ deps.notice(
1726
+ `Could not read ${what} in ${regions.join(", ")}; they show as unknown or are missing.`
1727
+ );
1728
+ }
1729
+ async function databaseListCommand(deps, options = {}) {
1730
+ const page = await deps.api.listDatabases(
1731
+ await deps.auth.apiKey(),
1732
+ { limit: options.limit, cursor: options.cursor },
1733
+ options.prefix
1734
+ );
1735
+ printPage(
1736
+ deps,
1737
+ options.json,
1738
+ page,
1739
+ (databases) => databases.length ? table(
1740
+ ["NAME", "KIND", "REGION", "STATE", "READ ACCESS", "LAST WRITE", "STORED", "URL"],
1741
+ databases.map((database) => [
1742
+ database.name,
1743
+ databaseOwner(database),
1744
+ database.region,
1745
+ database.state,
1746
+ databaseAccess(database),
1747
+ when(database.last_write_at),
1748
+ databaseSize(database),
1749
+ database.url
1750
+ ])
1751
+ ) : "No databases."
1752
+ );
1753
+ noticeUnavailable(deps, page.unavailable_regions, "sizes");
1754
+ }
1755
+ async function databaseCreateCommand(deps, name, options = {}) {
1756
+ const database = await deps.api.createDatabase(
1757
+ await deps.auth.apiKey(),
1758
+ { name, region: options.region },
1759
+ options.idempotencyKey ?? randomUUID2()
1760
+ );
1761
+ print(
1762
+ deps,
1763
+ options.json,
1764
+ database,
1765
+ [
1766
+ database.state === "active" ? `Created database ${database.name}.` : `Database ${database.name} is being created; \`tonbo database show ${database.name}\` reports it active when it is ready.`,
1767
+ databaseFields(database)
1768
+ ].join("\n")
1769
+ );
1770
+ if (!options.json) deps.notice(databaseNextStep(database.name));
1771
+ }
1772
+ function databaseNextStep(name) {
1773
+ return [
1774
+ "Next: npm i @tonbo/sqlite-ursula",
1775
+ ` open(${JSON.stringify(name)}) from "@tonbo/sqlite-ursula/tonbo"`
1776
+ ].join("\n");
1777
+ }
1778
+ async function databaseShowCommand(deps, name, options = {}) {
1779
+ const database = await deps.api.getDatabase(await deps.auth.apiKey(), name);
1780
+ print(deps, options.json, database, databaseFields(database));
1781
+ }
1782
+ async function databaseUpdateCommand(deps, name, options = {}) {
1783
+ if (options.public === options.private)
1784
+ throw new Error("Pass exactly one of --public and --private.");
1785
+ const database = await deps.api.updateDatabase(await deps.auth.apiKey(), name, {
1786
+ public_read: options.public === true
1787
+ });
1788
+ print(
1789
+ deps,
1790
+ options.json,
1791
+ database,
1792
+ database.public_read ? `Database ${database.name} is public: anyone can read it at ${database.url}.` : `Database ${database.name} is private: only the account's keys can read it.`
1793
+ );
1794
+ }
1795
+ async function databaseDeleteCommand(deps, name, options = {}) {
1796
+ await confirm(
1797
+ deps,
1798
+ options.yes,
1799
+ `delete database ${name}`,
1800
+ () => Promise.resolve(
1801
+ `Delete database ${name} and everything in it? This cannot be undone. Type the database's name to confirm:`
1802
+ ),
1803
+ name
1804
+ );
1805
+ const database = await deps.api.deleteDatabase(await deps.auth.apiKey(), name);
1806
+ print(
1807
+ deps,
1808
+ options.json,
1809
+ database,
1810
+ database.state === "deleted" ? `Deleted database ${database.name}; its name is free again.` : `Database ${database.name} is ${database.state}; its name is free again once it is deleted.`
1811
+ );
1812
+ }
1813
+ function databaseUsageSort(value) {
1814
+ if (value === "-list_value" || value === "list_value") return "-list_value";
1815
+ if (value === "name") return "name";
1816
+ throw new Error("--sort is list_value or name.");
1817
+ }
1818
+ async function databaseUsageCommand(deps, options = {}) {
1819
+ const usage = await deps.api.listDatabaseUsage(
1820
+ await deps.auth.apiKey(),
1821
+ { limit: options.limit, cursor: options.cursor },
1822
+ { period: options.period, sort: options.sort, prefix: options.prefix }
1823
+ );
1824
+ if (options.json) deps.output(JSON.stringify(usage));
1825
+ else
1826
+ deps.output(
1827
+ [
1828
+ `Database usage in ${usage.period} at list price, before the monthly allowance and credit (${usage.currency.toUpperCase()}; metered up to ${when(usage.as_of, "not yet metered")})`,
1829
+ usage.data.length ? table(
1830
+ [
1831
+ "NAME",
1832
+ "REGION",
1833
+ "STATE",
1834
+ "WRITE UNITS",
1835
+ "LOG GIB-MONTHS",
1836
+ "SNAPSHOT GIB-MONTHS",
1837
+ "LIST VALUE"
1838
+ ],
1839
+ usage.data.map((row) => [
1840
+ row.name,
1841
+ row.region,
1842
+ row.state,
1843
+ row.write_units,
1844
+ row.retention_gib_months,
1845
+ row.snapshot_gib_months ?? "",
1846
+ usd(row.list_value_micros)
1847
+ ])
1848
+ ) : "No database usage.",
1849
+ `Other metered usage: ${usd(usage.other.list_value_micros)}`,
1850
+ "Charges, after the allowance and credit, are in `tonbo usage show`."
1851
+ ].join("\n")
1852
+ );
1853
+ if (usage.next_cursor) deps.notice(`More results: run again with --cursor ${usage.next_cursor}`);
1854
+ noticeUnavailable(deps, usage.unavailable_regions, "usage");
1855
+ }
1315
1856
  async function usageShowCommand(deps, options = {}) {
1316
1857
  const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
1317
1858
  print(
@@ -1336,60 +1877,6 @@ async function usageShowCommand(deps, options = {}) {
1336
1877
  );
1337
1878
  }
1338
1879
 
1339
- // src/progress.ts
1340
- var FRAMES = ["|", "/", "-", "\\"];
1341
- var TerminalProgress = class {
1342
- constructor(stream, intervalMs = 80) {
1343
- this.stream = stream;
1344
- this.intervalMs = intervalMs;
1345
- }
1346
- activeMessage;
1347
- frame = 0;
1348
- lastWidth = 0;
1349
- timer;
1350
- start(message) {
1351
- if (this.activeMessage) this.succeed();
1352
- this.activeMessage = message;
1353
- this.frame = 0;
1354
- if (!this.stream.isTTY) {
1355
- this.stream.write(`[..] ${message}
1356
- `);
1357
- return;
1358
- }
1359
- this.render(`[${FRAMES[this.frame]}] ${message}`);
1360
- this.timer = setInterval(() => {
1361
- this.frame = (this.frame + 1) % FRAMES.length;
1362
- this.render(`[${FRAMES[this.frame]}] ${this.activeMessage}`);
1363
- }, this.intervalMs);
1364
- this.timer.unref();
1365
- }
1366
- succeed(message = this.activeMessage) {
1367
- this.finish("ok", message);
1368
- }
1369
- fail(message = this.activeMessage) {
1370
- this.finish("!!", message);
1371
- }
1372
- finish(marker, message) {
1373
- if (this.timer) clearInterval(this.timer);
1374
- this.timer = void 0;
1375
- this.activeMessage = void 0;
1376
- if (!message) return;
1377
- const line = `[${marker}] ${message}`;
1378
- if (this.stream.isTTY) {
1379
- this.render(line);
1380
- this.stream.write("\n");
1381
- this.lastWidth = 0;
1382
- return;
1383
- }
1384
- this.stream.write(`${line}
1385
- `);
1386
- }
1387
- render(value) {
1388
- this.stream.write(`\r${value.padEnd(this.lastWidth)}`);
1389
- this.lastWidth = value.length;
1390
- }
1391
- };
1392
-
1393
1880
  // src/prompt.ts
1394
1881
  import select from "@inquirer/select";
1395
1882
  import { createInterface } from "node:readline/promises";
@@ -1419,18 +1906,6 @@ async function terminalAsk(question) {
1419
1906
  var packageVersion = JSON.parse(
1420
1907
  readFileSync(new URL("../../package.json", import.meta.url), "utf8")
1421
1908
  );
1422
- function apiOrigin(configured = process.env.TONBO_API_ORIGIN) {
1423
- const url = new URL(configured?.trim() || DEFAULT_API_ORIGIN);
1424
- const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
1425
- if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
1426
- throw new Error("TONBO_API_ORIGIN must be an https origin.");
1427
- return url.origin;
1428
- }
1429
- function loginOrigin(api) {
1430
- const url = new URL(api);
1431
- if (url.hostname.startsWith("api.")) url.hostname = url.hostname.slice("api.".length);
1432
- return url.origin;
1433
- }
1434
1909
  function createDependencies() {
1435
1910
  const api = apiOrigin();
1436
1911
  const credentials = new FileCredentialStore();
@@ -1458,6 +1933,7 @@ function operationOptions(command, operationId) {
1458
1933
  command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
1459
1934
  return command.option("--json", "print the API data as JSON");
1460
1935
  }
1936
+ var repeated = (value, previous = []) => [...previous, value];
1461
1937
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
1462
1938
  function createProgram(dependencies = createDependencies) {
1463
1939
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
@@ -1513,7 +1989,15 @@ function createProgram(dependencies = createDependencies) {
1513
1989
  ).option("--read-only", "narrow the key to read-only actions, for its whole life").option(
1514
1990
  "--bucket <name>",
1515
1991
  "narrow the key to this bucket, for its whole life; repeat for several",
1516
- (value, previous = []) => [...previous, value]
1992
+ repeated
1993
+ ).option(
1994
+ "--database <name>",
1995
+ "narrow the key to this database, for its whole life; repeat for several; never with --bucket",
1996
+ repeated
1997
+ ).option(
1998
+ "--database-prefix <prefix>",
1999
+ "narrow the key to the databases whose name starts with this, for its whole life; repeat for several; never with --bucket",
2000
+ repeated
1517
2001
  ).addOption(
1518
2002
  new Option("--expires-in <period>", "when the key expires; never by default").choices(
1519
2003
  apiKeyExpirations
@@ -1563,6 +2047,62 @@ function createProgram(dependencies = createDependencies) {
1563
2047
  ).action(
1564
2048
  async (name, options) => bucketDeleteCommand(dependencies(), name, options)
1565
2049
  );
2050
+ const database = program.command("database").description("the account's databases");
2051
+ operationOptions(
2052
+ database.command("list").description("list the databases in every region").option("--prefix <prefix>", "only databases whose name starts with this"),
2053
+ "listDatabases"
2054
+ ).action(
2055
+ async (options) => databaseListCommand(dependencies(), options)
2056
+ );
2057
+ operationOptions(
2058
+ database.command("create <name>").description("create a database; its region is fixed for its life").option(
2059
+ "--region <region>",
2060
+ "the database's region (see tonbo region list); the database default region by default"
2061
+ ).option(
2062
+ "--idempotency-key <key>",
2063
+ "reuse the key of an unconfirmed creation to get the database it created"
2064
+ ),
2065
+ "createDatabase"
2066
+ ).action(
2067
+ async (name, options) => databaseCreateCommand(dependencies(), name, options)
2068
+ );
2069
+ operationOptions(
2070
+ database.command("show <name>").description("show a database, its URL and size"),
2071
+ "getDatabase"
2072
+ ).action(
2073
+ async (name, options) => databaseShowCommand(dependencies(), name, options)
2074
+ );
2075
+ operationOptions(
2076
+ database.command("update <name>").description("publish a database for anonymous reads, or take it private (owners and admins)").addOption(new Option("--public", "serve anonymous reads").conflicts("private")).addOption(new Option("--private", "serve only the account's keys").conflicts("public")),
2077
+ "updateDatabase"
2078
+ ).action(
2079
+ async (name, options) => databaseUpdateCommand(dependencies(), name, options)
2080
+ );
2081
+ operationOptions(
2082
+ database.command("delete <name>").description("delete a database and everything in it").option("--yes", "delete without asking; required when stdin is not a terminal"),
2083
+ "deleteDatabase"
2084
+ ).action(
2085
+ async (name, options) => databaseDeleteCommand(dependencies(), name, options)
2086
+ );
2087
+ operationOptions(
2088
+ database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option(
2089
+ "--period <month>",
2090
+ "the calendar month in UTC, as YYYY-MM; the current one by default"
2091
+ ).option(
2092
+ "--sort <order>",
2093
+ "list_value (highest first, the default) or name",
2094
+ (value) => {
2095
+ try {
2096
+ return databaseUsageSort(value);
2097
+ } catch (error) {
2098
+ throw new InvalidArgumentError(error.message);
2099
+ }
2100
+ }
2101
+ ).option("--prefix <prefix>", "only databases whose name starts with this"),
2102
+ "listDatabaseUsage"
2103
+ ).action(
2104
+ async (options) => databaseUsageCommand(dependencies(), options)
2105
+ );
1566
2106
  operationOptions(
1567
2107
  program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
1568
2108
  "--period <month>",