@tonbo/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +13 -7
  2. package/dist/bin/tonbo.js +850 -502
  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 os 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,193 @@ 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);
178
+ }
179
+
180
+ // ../../packages/tonbo-cli-core/dist/src/auth.js
181
+ var exec = promisify(execFile);
182
+ var DEFAULT_LOGIN_HINT = "tonbo login";
183
+ var LOOPBACK_HOST = "127.0.0.1";
184
+ function loginHostname(hostname = os.hostname()) {
185
+ const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
186
+ return cleaned || "computer";
187
+ }
188
+ var AuthClient = class {
189
+ credentials;
190
+ fetcher;
191
+ loginOrigin;
192
+ browserOpener;
193
+ hostname;
194
+ loginHint;
195
+ constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
196
+ this.credentials = credentials;
197
+ this.fetcher = fetcher;
198
+ this.loginOrigin = loginOrigin2;
199
+ this.browserOpener = browserOpener;
200
+ this.hostname = hostname;
201
+ this.loginHint = loginHint;
202
+ }
203
+ /** The personal key requests are made with: TONBO_API_KEY, or the saved
204
+ * login while it has not expired. */
205
+ async apiKey() {
206
+ const injected = process.env.TONBO_API_KEY?.trim();
207
+ if (injected)
208
+ return injected;
209
+ const login = await this.credentials.load();
210
+ if (!login)
211
+ throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
212
+ if (login.expires_at && Date.parse(login.expires_at) <= Date.now())
213
+ throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
214
+ return login.key;
215
+ }
216
+ /**
217
+ * The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
218
+ * a free port, then the one-time code is redeemed for a personal key named
219
+ * "CLI on <hostname>", which replaces that host's earlier login key.
220
+ */
221
+ async login(progress = () => {
222
+ }, account) {
223
+ const verifier = randomBytes(48).toString("base64url");
224
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
225
+ const state = randomBytes(24).toString("base64url");
226
+ const code = await this.listenForCode(state, (redirectUri) => {
227
+ const authorization = new URL("/cli/login", this.loginOrigin);
228
+ authorization.search = new URLSearchParams({
229
+ code_challenge: challenge,
230
+ code_challenge_method: "S256",
231
+ redirect_uri: redirectUri,
232
+ state,
233
+ hostname: this.hostname,
234
+ ...account ? { account } : {}
235
+ }).toString();
236
+ return this.browserOpener(authorization.toString());
237
+ }, progress);
238
+ progress({ status: "started", step: "key-redemption" });
239
+ const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json" },
242
+ body: JSON.stringify({ code, code_verifier: verifier })
243
+ });
244
+ progress({ status: "completed", step: "key-redemption" });
245
+ const login = {
246
+ version: 1,
247
+ account_id: redeemed.account_id,
248
+ key_id: redeemed.api_key.id,
249
+ key: redeemed.key,
250
+ expires_at: redeemed.api_key.expires_at
251
+ };
252
+ progress({ status: "started", step: "credential-store" });
253
+ await this.credentials.save(login);
254
+ progress({ status: "completed", step: "credential-store" });
255
+ return login;
256
+ }
257
+ listenForCode(expectedState, ready, progress) {
258
+ return new Promise((resolve, reject) => {
259
+ let settled = false;
260
+ const sockets = /* @__PURE__ */ new Set();
261
+ let authorizationStarted = false;
262
+ const startAuthorization = () => {
263
+ if (authorizationStarted)
264
+ return;
265
+ authorizationStarted = true;
266
+ progress({ status: "completed", step: "browser" });
267
+ progress({ status: "started", step: "browser-authorization" });
268
+ };
269
+ const finish = (result, completedResponseSocket) => {
270
+ if (settled)
271
+ return;
272
+ settled = true;
273
+ clearTimeout(timeout);
274
+ const complete = (closeError) => {
275
+ if (closeError)
276
+ reject(closeError);
277
+ else if ("code" in result) {
278
+ progress({ status: "completed", step: "callback-close" });
279
+ resolve(result.code);
280
+ } else
281
+ reject(result.error);
282
+ };
283
+ if ("code" in result)
284
+ progress({ status: "started", step: "callback-close" });
285
+ if (!server.listening) {
286
+ complete();
287
+ return;
288
+ }
289
+ server.close(complete);
290
+ for (const socket of sockets) {
291
+ if (socket !== completedResponseSocket)
292
+ socket.destroy();
293
+ }
294
+ server.closeIdleConnections();
295
+ };
296
+ const timeout = setTimeout(() => {
297
+ finish({ error: new Error("Timed out waiting for browser login.") });
298
+ }, 5 * 60 * 1e3);
299
+ const server = createServer((request, response) => {
300
+ const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
301
+ if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
302
+ respond(response, "invalid");
303
+ return;
304
+ }
305
+ const denied = url.searchParams.get("error");
306
+ if (denied) {
307
+ const responseSocket2 = response.socket;
308
+ respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
309
+ return;
310
+ }
311
+ const code = url.searchParams.get("code");
312
+ if (!code) {
313
+ respond(response, "invalid");
314
+ return;
315
+ }
316
+ startAuthorization();
317
+ progress({ status: "completed", step: "browser-authorization" });
318
+ const responseSocket = response.socket;
319
+ respond(response, "complete", () => finish({ code }, responseSocket));
320
+ });
321
+ server.on("connection", (socket) => {
322
+ sockets.add(socket);
323
+ socket.once("close", () => sockets.delete(socket));
324
+ });
325
+ server.once("error", (error) => finish({ error }));
326
+ progress({ status: "started", step: "callback-server" });
327
+ server.listen(0, LOOPBACK_HOST, () => {
328
+ const { port } = server.address();
329
+ progress({ status: "completed", step: "callback-server" });
330
+ progress({ status: "started", step: "browser" });
331
+ void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
332
+ error: error instanceof Error ? error : new Error("Could not open the login browser.")
333
+ }));
334
+ });
335
+ });
336
+ }
337
+ };
338
+ function respond(response, outcome, finished) {
339
+ const page = callbackResponse(outcome);
340
+ if (finished)
341
+ response.once("finish", finished);
342
+ response.writeHead(page.status, { ...page.headers, connection: "close" });
343
+ response.end(page.body);
344
+ }
345
+ async function openBrowser(url) {
346
+ if (process.platform === "darwin")
347
+ return void await exec("open", [url]);
348
+ if (process.platform === "win32")
349
+ return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
350
+ return void await exec("xdg-open", [url]);
42
351
  }
43
352
 
44
- // src/generated/contracts.ts
353
+ // ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
45
354
  var agentNameContract = {
46
355
  "$schema": "https://json-schema.org/draft/2020-12/schema",
47
356
  "$id": "https://contracts.tonbo.dev/agents/agent-name-v1.json",
@@ -343,6 +652,70 @@ var managementOperations = {
343
652
  ],
344
653
  "idempotencyKey": false,
345
654
  "cli": "tonbo usage show"
655
+ },
656
+ "listDatabases": {
657
+ "method": "GET",
658
+ "path": "/databases",
659
+ "pathParams": [],
660
+ "query": [
661
+ "prefix",
662
+ "cursor",
663
+ "limit"
664
+ ],
665
+ "idempotencyKey": false,
666
+ "cli": "tonbo database list"
667
+ },
668
+ "createDatabase": {
669
+ "method": "POST",
670
+ "path": "/databases",
671
+ "pathParams": [],
672
+ "query": [],
673
+ "idempotencyKey": true,
674
+ "cli": "tonbo database create"
675
+ },
676
+ "getDatabase": {
677
+ "method": "GET",
678
+ "path": "/databases/{name}",
679
+ "pathParams": [
680
+ "name"
681
+ ],
682
+ "query": [],
683
+ "idempotencyKey": false,
684
+ "cli": "tonbo database show"
685
+ },
686
+ "updateDatabase": {
687
+ "method": "PATCH",
688
+ "path": "/databases/{name}",
689
+ "pathParams": [
690
+ "name"
691
+ ],
692
+ "query": [],
693
+ "idempotencyKey": false,
694
+ "cli": "tonbo database update"
695
+ },
696
+ "deleteDatabase": {
697
+ "method": "DELETE",
698
+ "path": "/databases/{name}",
699
+ "pathParams": [
700
+ "name"
701
+ ],
702
+ "query": [],
703
+ "idempotencyKey": false,
704
+ "cli": "tonbo database delete"
705
+ },
706
+ "listDatabaseUsage": {
707
+ "method": "GET",
708
+ "path": "/usage/databases",
709
+ "pathParams": [],
710
+ "query": [
711
+ "period",
712
+ "sort",
713
+ "prefix",
714
+ "cursor",
715
+ "limit"
716
+ ],
717
+ "idempotencyKey": false,
718
+ "cli": "tonbo database usage"
346
719
  }
347
720
  };
348
721
  var accountRoles = [
@@ -358,31 +731,38 @@ var apiKeyExpirations = [
358
731
  "never"
359
732
  ];
360
733
 
361
- // src/api.ts
734
+ // ../../packages/tonbo-cli-core/dist/src/api.js
362
735
  var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
363
736
  var ManagementApi = class {
364
- constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN) {
737
+ fetcher;
738
+ apiOrigin;
739
+ loginHint;
740
+ constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT) {
365
741
  this.fetcher = fetcher;
366
742
  this.apiOrigin = apiOrigin2;
743
+ this.loginHint = loginHint;
367
744
  }
368
745
  /** The answer's `data`. A refusal throws an HttpError naming the request id. */
369
746
  async call(operationId, key, request = {}) {
370
747
  return (await this.request(operationId, key, request)).data;
371
748
  }
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 } });
749
+ /** One page of a list operation: its `data` and `next_cursor`, with the
750
+ * operation's other query parameters, if any. */
751
+ page(operationId, key, { limit, cursor } = {}, query = {}) {
752
+ return this.request(operationId, key, { query: { ...query, limit, cursor } });
375
753
  }
376
754
  async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
377
755
  const operation = managementOperations[operationId];
378
756
  const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
379
757
  const value = params[name];
380
- if (value === void 0) throw new Error(`${operationId} needs the path parameter ${name}.`);
758
+ if (value === void 0)
759
+ throw new Error(`${operationId} needs the path parameter ${name}.`);
381
760
  return encodeURIComponent(value);
382
761
  });
383
762
  const search = new URLSearchParams();
384
763
  for (const [name, value] of Object.entries(query)) {
385
- if (value === void 0) continue;
764
+ if (value === void 0)
765
+ continue;
386
766
  if (!operation.query.includes(name))
387
767
  throw new Error(`${operationId} takes no query parameter ${name}.`);
388
768
  search.set(name, String(value));
@@ -390,8 +770,10 @@ var ManagementApi = class {
390
770
  if (operation.idempotencyKey && !idempotencyKey)
391
771
  throw new Error(`${operationId} needs an Idempotency-Key.`);
392
772
  const headers = { authorization: `Bearer ${key}` };
393
- if (body !== void 0) headers["content-type"] = "application/json";
394
- if (idempotencyKey) headers["idempotency-key"] = idempotencyKey;
773
+ if (body !== void 0)
774
+ headers["content-type"] = "application/json";
775
+ if (idempotencyKey)
776
+ headers["idempotency-key"] = idempotencyKey;
395
777
  const queryString = search.toString();
396
778
  const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
397
779
  try {
@@ -402,12 +784,7 @@ var ManagementApi = class {
402
784
  });
403
785
  } catch (error) {
404
786
  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
- );
787
+ throw new HttpError(`${error.message} Run \`${this.loginHint}\`.`, error.status, error.body, error.requestId);
411
788
  throw error;
412
789
  }
413
790
  }
@@ -450,357 +827,63 @@ var ManagementApi = class {
450
827
  listBuckets(key, page) {
451
828
  return this.page("listBuckets", key, page);
452
829
  }
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 } });
469
- }
470
- getUsage(key, period) {
471
- return this.call("getUsage", key, { query: { period } });
472
- }
473
- };
474
- function withoutUndefined(value) {
475
- return Object.fromEntries(
476
- Object.entries(value).filter(([, field]) => field !== void 0)
477
- );
478
- }
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
- };
487
- }
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
- }
830
+ /** A replay with the same idempotency key returns the bucket the first
831
+ * request created. */
832
+ createBucket(key, request, idempotencyKey) {
833
+ return this.call("createBucket", key, {
834
+ body: withoutUndefined(request),
835
+ idempotencyKey
836
+ }).catch(unconfirmed("Bucket creation", idempotencyKey, "the same name and region"));
548
837
  }
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;
838
+ getBucket(key, name) {
839
+ return this.call("getBucket", key, { params: { name } });
560
840
  }
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;
841
+ updateBucket(key, name, change) {
842
+ return this.call("updateBucket", key, { params: { name }, body: change });
577
843
  }
578
- main {
579
- display: flex;
580
- flex-direction: column;
581
- justify-content: center;
582
- max-width: 46ch;
583
- padding-bottom: 12vh;
844
+ deleteBucket(key, name) {
845
+ return this.call("deleteBucket", key, { params: { name } });
584
846
  }
585
- h1 {
586
- margin: 0;
587
- font-size: 26px;
588
- font-weight: 300;
589
- line-height: 1.16;
590
- letter-spacing: -0.018em;
847
+ listDatabases(key, page, prefix) {
848
+ return this.page("listDatabases", key, page, { prefix });
591
849
  }
592
- p {
593
- margin: 14px 0 0;
594
- font-size: 16px;
595
- line-height: 1.55;
596
- color: var(--quiet);
850
+ /** A replay with the same idempotency key returns the database the first
851
+ * request created. */
852
+ createDatabase(key, request, idempotencyKey) {
853
+ return this.call("createDatabase", key, {
854
+ body: withoutUndefined(request),
855
+ idempotencyKey
856
+ }).catch(unconfirmed("Database creation", idempotencyKey, "the same name and region"));
597
857
  }
598
- hr {
599
- width: 40px;
600
- margin: 28px 0 0;
601
- border: 0;
602
- border-top: 1px solid var(--rule);
858
+ getDatabase(key, name) {
859
+ return this.call("getDatabase", key, { params: { name } });
603
860
  }
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
- }
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
- };
626
- }
627
-
628
- // src/auth.ts
629
- var exec = promisify(execFile);
630
- var LOOPBACK_HOST = "127.0.0.1";
631
- function loginHostname(hostname = os.hostname()) {
632
- const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
633
- return cleaned || "computer";
634
- }
635
- var AuthClient = class {
636
- constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname()) {
637
- this.credentials = credentials;
638
- this.fetcher = fetcher;
639
- this.loginOrigin = loginOrigin2;
640
- this.browserOpener = browserOpener;
641
- this.hostname = hostname;
861
+ updateDatabase(key, name, change) {
862
+ return this.call("updateDatabase", key, { params: { name }, body: change });
642
863
  }
643
- /** The personal key requests are made with: TONBO_API_KEY, or the saved
644
- * login while it has not expired. */
645
- async apiKey() {
646
- const injected = process.env.TONBO_API_KEY?.trim();
647
- if (injected) return injected;
648
- 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`.");
652
- return login.key;
864
+ deleteDatabase(key, name) {
865
+ return this.call("deleteDatabase", key, { params: { name } });
653
866
  }
654
- /**
655
- * The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
656
- * a free port, then the one-time code is redeemed for a personal key named
657
- * "CLI on <hostname>", which replaces that host's earlier login key.
658
- */
659
- async login(progress = () => {
660
- }, account) {
661
- const verifier = randomBytes(48).toString("base64url");
662
- const challenge = createHash("sha256").update(verifier).digest("base64url");
663
- 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
- );
680
- 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
- );
690
- progress({ status: "completed", step: "key-redemption" });
691
- const login = {
692
- version: 1,
693
- account_id: redeemed.account_id,
694
- key_id: redeemed.api_key.id,
695
- key: redeemed.key,
696
- expires_at: redeemed.api_key.expires_at
697
- };
698
- progress({ status: "started", step: "credential-store" });
699
- await this.credentials.save(login);
700
- progress({ status: "completed", step: "credential-store" });
701
- return login;
867
+ /** The whole usage page: its rows, `other`, prices and checkpoint. */
868
+ listDatabaseUsage(key, page, query = {}) {
869
+ return this.page("listDatabaseUsage", key, page, query);
702
870
  }
703
- listenForCode(expectedState, ready, progress) {
704
- return new Promise((resolve, reject) => {
705
- let settled = false;
706
- const sockets = /* @__PURE__ */ new Set();
707
- let authorizationStarted = false;
708
- const startAuthorization = () => {
709
- if (authorizationStarted) return;
710
- authorizationStarted = true;
711
- progress({ status: "completed", step: "browser" });
712
- progress({ status: "started", step: "browser-authorization" });
713
- };
714
- const finish = (result, completedResponseSocket) => {
715
- if (settled) return;
716
- settled = true;
717
- clearTimeout(timeout);
718
- const complete = (closeError) => {
719
- if (closeError) reject(closeError);
720
- else if ("code" in result) {
721
- progress({ status: "completed", step: "callback-close" });
722
- resolve(result.code);
723
- } else reject(result.error);
724
- };
725
- if ("code" in result) progress({ status: "started", step: "callback-close" });
726
- if (!server.listening) {
727
- complete();
728
- return;
729
- }
730
- server.close(complete);
731
- for (const socket of sockets) {
732
- if (socket !== completedResponseSocket) socket.destroy();
733
- }
734
- server.closeIdleConnections();
735
- };
736
- const timeout = setTimeout(
737
- () => {
738
- finish({ error: new Error("Timed out waiting for browser login.") });
739
- },
740
- 5 * 60 * 1e3
741
- );
742
- const server = createServer((request, response) => {
743
- const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
744
- if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
745
- respond(response, "invalid");
746
- return;
747
- }
748
- const denied = url.searchParams.get("error");
749
- if (denied) {
750
- const responseSocket2 = response.socket;
751
- respond(
752
- response,
753
- "denied",
754
- () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2)
755
- );
756
- return;
757
- }
758
- const code = url.searchParams.get("code");
759
- if (!code) {
760
- respond(response, "invalid");
761
- return;
762
- }
763
- startAuthorization();
764
- progress({ status: "completed", step: "browser-authorization" });
765
- const responseSocket = response.socket;
766
- respond(response, "complete", () => finish({ code }, responseSocket));
767
- });
768
- server.on("connection", (socket) => {
769
- sockets.add(socket);
770
- socket.once("close", () => sockets.delete(socket));
771
- });
772
- server.once("error", (error) => finish({ error }));
773
- progress({ status: "started", step: "callback-server" });
774
- server.listen(0, LOOPBACK_HOST, () => {
775
- const { port } = server.address();
776
- progress({ status: "completed", step: "callback-server" });
777
- 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
- );
783
- });
784
- });
871
+ getUsage(key, period) {
872
+ return this.call("getUsage", key, { query: { period } });
785
873
  }
786
874
  };
787
- function respond(response, outcome, finished) {
788
- const page = callbackResponse(outcome);
789
- if (finished) response.once("finish", finished);
790
- response.writeHead(page.status, { ...page.headers, connection: "close" });
791
- response.end(page.body);
875
+ function withoutUndefined(value) {
876
+ return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
792
877
  }
793
- async function openBrowser(url) {
794
- if (process.platform === "darwin") return void await exec("open", [url]);
795
- if (process.platform === "win32")
796
- return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
797
- return void await exec("xdg-open", [url]);
878
+ function unconfirmed(what, idempotencyKey, same) {
879
+ return (cause) => {
880
+ if (cause instanceof HttpError && cause.status < 500)
881
+ throw cause;
882
+ throw new Error(`${what} was not confirmed: ${cause instanceof Error ? cause.message : String(cause)} Retry with --idempotency-key ${idempotencyKey} and ${same}.`, { cause });
883
+ };
798
884
  }
799
885
 
800
- // src/commands.ts
801
- import { randomUUID as randomUUID2 } from "node:crypto";
802
-
803
- // src/credentials.ts
886
+ // ../../packages/tonbo-cli-core/dist/src/credentials.js
804
887
  import { randomUUID } from "node:crypto";
805
888
  import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
806
889
  import os2 from "node:os";
@@ -812,8 +895,11 @@ function defaultConfigDirectory() {
812
895
  var UnusableLoginError = class extends Error {
813
896
  };
814
897
  var FileCredentialStore = class {
815
- constructor(filename = path.join(defaultConfigDirectory(), "credentials.json")) {
898
+ filename;
899
+ loginHint;
900
+ constructor(filename = path.join(defaultConfigDirectory(), "credentials.json"), loginHint = DEFAULT_LOGIN_HINT) {
816
901
  this.filename = filename;
902
+ this.loginHint = loginHint;
817
903
  }
818
904
  async load() {
819
905
  let parsed;
@@ -821,25 +907,22 @@ var FileCredentialStore = class {
821
907
  await assertPrivateRegularFile(this.filename);
822
908
  parsed = JSON.parse(await readFile(this.filename, "utf8"));
823
909
  } catch (error) {
824
- if (error.code === "ENOENT") return null;
910
+ if (error.code === "ENOENT")
911
+ return null;
825
912
  throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
826
913
  }
827
914
  if (!isStoredLogin(parsed))
828
- throw new UnusableLoginError(
829
- `The saved login at ${this.filename} is not usable by this CLI. Run \`tonbo login\`.`
830
- );
915
+ throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
831
916
  return parsed;
832
917
  }
833
918
  async save(login) {
834
- if (!isStoredLogin(login)) throw new Error("Refusing to store a malformed Tonbo login.");
919
+ if (!isStoredLogin(login))
920
+ throw new Error("Refusing to store a malformed Tonbo login.");
835
921
  const directory = path.dirname(this.filename);
836
922
  await mkdir(directory, { mode: 448, recursive: true });
837
923
  await preparePrivateDirectory(directory);
838
924
  await assertExistingDestinationIsSafe(this.filename);
839
- const temporary = path.join(
840
- directory,
841
- `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`
842
- );
925
+ const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
843
926
  let handle = null;
844
927
  try {
845
928
  handle = await open(temporary, "wx", 384);
@@ -848,7 +931,8 @@ var FileCredentialStore = class {
848
931
  await handle.sync();
849
932
  await handle.close();
850
933
  handle = null;
851
- if (process.platform !== "win32") await chmod(temporary, 384);
934
+ if (process.platform !== "win32")
935
+ await chmod(temporary, 384);
852
936
  await rename(temporary, this.filename);
853
937
  } catch (error) {
854
938
  await handle?.close().catch(() => void 0);
@@ -862,7 +946,8 @@ var FileCredentialStore = class {
862
946
  }
863
947
  };
864
948
  function isStoredLogin(value) {
865
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
949
+ if (!value || typeof value !== "object" || Array.isArray(value))
950
+ return false;
866
951
  const login = value;
867
952
  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
953
  }
@@ -870,7 +955,8 @@ async function preparePrivateDirectory(directory) {
870
955
  const stat = await lstat(directory);
871
956
  if (stat.isSymbolicLink() || !stat.isDirectory())
872
957
  throw new Error("Tonbo config directory must be a real directory.");
873
- if (process.platform !== "win32") await chmod(directory, 448);
958
+ if (process.platform !== "win32")
959
+ await chmod(directory, 448);
874
960
  }
875
961
  async function assertExistingDestinationIsSafe(filename) {
876
962
  try {
@@ -878,7 +964,8 @@ async function assertExistingDestinationIsSafe(filename) {
878
964
  if (stat.isSymbolicLink() || !stat.isFile())
879
965
  throw new Error("Tonbo credential path must be a regular file.");
880
966
  } catch (error) {
881
- if (error.code !== "ENOENT") throw error;
967
+ if (error.code !== "ENOENT")
968
+ throw error;
882
969
  }
883
970
  }
884
971
  async function assertPrivateRegularFile(filename) {
@@ -889,27 +976,10 @@ async function assertPrivateRegularFile(filename) {
889
976
  throw new Error("Tonbo credential file must have mode 0600.");
890
977
  }
891
978
 
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
- );
902
- }
903
- }
904
-
905
- // src/format.ts
979
+ // ../../packages/tonbo-cli-core/dist/src/format.js
906
980
  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");
981
+ const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
982
+ return [headings, ...rows].map((row) => row.map((cell, column) => column === row.length - 1 ? cell : cell.padEnd(widths[column])).join(" ").trimEnd()).join("\n");
913
983
  }
914
984
  function fields(pairs) {
915
985
  const width = Math.max(...pairs.map(([label]) => label.length + 1));
@@ -921,37 +991,34 @@ var USD = new Intl.NumberFormat("en-US", {
921
991
  minimumFractionDigits: 2,
922
992
  maximumFractionDigits: 6
923
993
  });
924
- function usd(micros) {
925
- return USD.format(micros / 1e6);
926
- }
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.");
994
+ function usd(micros) {
995
+ return USD.format(micros / 1e6);
996
+ }
997
+ var BYTE_UNITS = ["KiB", "MiB", "GiB", "TiB"];
998
+ function bytes(count, absent = "unknown") {
999
+ if (count === null)
1000
+ return absent;
1001
+ if (count < 1024)
1002
+ return `${count} B`;
1003
+ let value = count;
1004
+ let unit = "B";
1005
+ for (const next of BYTE_UNITS) {
1006
+ if (value < 1024)
1007
+ break;
1008
+ value /= 1024;
1009
+ unit = next;
1010
+ }
1011
+ return `${value.toLocaleString("en-US", { maximumFractionDigits: 1 })} ${unit}`;
945
1012
  }
1013
+ function when(timestamp, absent = "never") {
1014
+ return timestamp ?? absent;
1015
+ }
1016
+
1017
+ // ../../packages/tonbo-cli-core/dist/src/login.js
946
1018
  async function loginCommand(deps, options = {}) {
947
1019
  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
- );
1020
+ const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account);
1021
+ deps.output(`Logged in to account ${login.account_id}.${login.expires_at ? ` The login expires ${login.expires_at}.` : ""}`);
955
1022
  } catch (error) {
956
1023
  deps.progress.fail();
957
1024
  throw error;
@@ -985,15 +1052,18 @@ var LOGIN_PROGRESS_MESSAGES = {
985
1052
  };
986
1053
  function reportLoginProgress(progress, event) {
987
1054
  const messages = LOGIN_PROGRESS_MESSAGES[event.step];
988
- if (event.status === "started") progress.start(messages.started);
989
- else progress.succeed(messages.completed);
1055
+ if (event.status === "started")
1056
+ progress.start(messages.started);
1057
+ else
1058
+ progress.succeed(messages.completed);
990
1059
  }
991
1060
  async function logoutCommand(deps) {
992
1061
  let login;
993
1062
  try {
994
1063
  login = await deps.credentials.load();
995
1064
  } catch (error) {
996
- if (!(error instanceof UnusableLoginError)) throw error;
1065
+ if (!(error instanceof UnusableLoginError))
1066
+ throw error;
997
1067
  await deps.credentials.remove();
998
1068
  deps.output("Removed a saved login this CLI cannot use; it had no key to revoke.");
999
1069
  return;
@@ -1011,11 +1081,114 @@ async function logoutCommand(deps) {
1011
1081
  }
1012
1082
  await deps.credentials.remove();
1013
1083
  if (failure)
1084
+ 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 });
1085
+ deps.output("Logged out. The CLI key is revoked.");
1086
+ }
1087
+
1088
+ // ../../packages/tonbo-cli-core/dist/src/origin.js
1089
+ function apiOrigin(configured = process.env.TONBO_API_ORIGIN) {
1090
+ const url = new URL(configured?.trim() || DEFAULT_API_ORIGIN);
1091
+ const loopback = ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname);
1092
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback))
1093
+ throw new Error("TONBO_API_ORIGIN must be an https origin.");
1094
+ return url.origin;
1095
+ }
1096
+ function loginOrigin(api) {
1097
+ const url = new URL(api);
1098
+ if (url.hostname.startsWith("api."))
1099
+ url.hostname = url.hostname.slice("api.".length);
1100
+ return url.origin;
1101
+ }
1102
+
1103
+ // ../../packages/tonbo-cli-core/dist/src/progress.js
1104
+ var FRAMES = ["|", "/", "-", "\\"];
1105
+ var TerminalProgress = class {
1106
+ stream;
1107
+ intervalMs;
1108
+ activeMessage;
1109
+ frame = 0;
1110
+ lastWidth = 0;
1111
+ timer;
1112
+ constructor(stream, intervalMs = 80) {
1113
+ this.stream = stream;
1114
+ this.intervalMs = intervalMs;
1115
+ }
1116
+ start(message) {
1117
+ if (this.activeMessage)
1118
+ this.succeed();
1119
+ this.activeMessage = message;
1120
+ this.frame = 0;
1121
+ if (!this.stream.isTTY) {
1122
+ this.stream.write(`[..] ${message}
1123
+ `);
1124
+ return;
1125
+ }
1126
+ this.render(`[${FRAMES[this.frame]}] ${message}`);
1127
+ this.timer = setInterval(() => {
1128
+ this.frame = (this.frame + 1) % FRAMES.length;
1129
+ this.render(`[${FRAMES[this.frame]}] ${this.activeMessage}`);
1130
+ }, this.intervalMs);
1131
+ this.timer.unref();
1132
+ }
1133
+ succeed(message = this.activeMessage) {
1134
+ this.finish("ok", message);
1135
+ }
1136
+ fail(message = this.activeMessage) {
1137
+ this.finish("!!", message);
1138
+ }
1139
+ finish(marker, message) {
1140
+ if (this.timer)
1141
+ clearInterval(this.timer);
1142
+ this.timer = void 0;
1143
+ this.activeMessage = void 0;
1144
+ if (!message)
1145
+ return;
1146
+ const line = `[${marker}] ${message}`;
1147
+ if (this.stream.isTTY) {
1148
+ this.render(line);
1149
+ this.stream.write("\n");
1150
+ this.lastWidth = 0;
1151
+ return;
1152
+ }
1153
+ this.stream.write(`${line}
1154
+ `);
1155
+ }
1156
+ render(value) {
1157
+ this.stream.write(`\r${value.padEnd(this.lastWidth)}`);
1158
+ this.lastWidth = value.length;
1159
+ }
1160
+ };
1161
+
1162
+ // src/commands.ts
1163
+ import { randomUUID as randomUUID2 } from "node:crypto";
1164
+
1165
+ // src/agent-name.ts
1166
+ var AGENT_NAME_RULE = agentNameContract.$defs.name;
1167
+ var AGENT_NAME_PATTERN = new RegExp(AGENT_NAME_RULE.pattern);
1168
+ var RESERVED_AGENT_NAMES = new Set(AGENT_NAME_RULE.not.enum);
1169
+ function validateAgentName(name) {
1170
+ const valid = name.length >= AGENT_NAME_RULE.minLength && name.length <= AGENT_NAME_RULE.maxLength && AGENT_NAME_PATTERN.test(name) && !RESERVED_AGENT_NAMES.has(name);
1171
+ if (!valid) {
1014
1172
  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 }
1173
+ `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
1174
  );
1018
- deps.output("Logged out. The CLI key is revoked.");
1175
+ }
1176
+ }
1177
+
1178
+ // src/commands.ts
1179
+ function print(deps, json, data, human) {
1180
+ deps.output(json ? JSON.stringify(data) : human);
1181
+ }
1182
+ function printPage(deps, json, page, human) {
1183
+ print(deps, json, page.data, human(page.data));
1184
+ if (page.next_cursor) deps.notice(`More results: run again with --cursor ${page.next_cursor}`);
1185
+ }
1186
+ async function confirm(deps, yes, action, question, expected) {
1187
+ if (yes) return;
1188
+ if (!deps.interactive())
1189
+ throw new Error(`Refusing to ${action} without confirmation: pass --yes.`);
1190
+ if (await deps.ask(await question()) !== expected)
1191
+ throw new Error("Cancelled; nothing was changed.");
1019
1192
  }
1020
1193
  async function regionListCommand(deps, options = {}) {
1021
1194
  const regions = await deps.api.listRegions(await deps.auth.apiKey());
@@ -1024,8 +1197,8 @@ async function regionListCommand(deps, options = {}) {
1024
1197
  options.json,
1025
1198
  regions,
1026
1199
  regions.length ? table(
1027
- ["REGION", "URL"],
1028
- regions.map((region) => [region.id, region.url])
1200
+ ["REGION", "URL", "DATABASE DEFAULT"],
1201
+ regions.map((region) => [region.id, region.url, region.default ? "yes" : ""])
1029
1202
  ) : "No region accepts new resources."
1030
1203
  );
1031
1204
  }
@@ -1145,8 +1318,19 @@ async function memberUpdateCommand(deps, member, options = {}) {
1145
1318
  function keyAccess(apiKey) {
1146
1319
  return apiKey.read_only ? "read-only" : "read-write";
1147
1320
  }
1321
+ function narrowedByDatabase(apiKey) {
1322
+ return apiKey.databases.length + apiKey.database_prefixes.length > 0;
1323
+ }
1148
1324
  function keyBuckets(apiKey) {
1149
- return apiKey.buckets.length ? apiKey.buckets.join(", ") : "all";
1325
+ if (apiKey.buckets.length) return apiKey.buckets.join(", ");
1326
+ return narrowedByDatabase(apiKey) ? "none" : "all";
1327
+ }
1328
+ function keyDatabases(apiKey) {
1329
+ if (narrowedByDatabase(apiKey))
1330
+ return [...apiKey.databases, ...apiKey.database_prefixes.map((prefix) => `${prefix}*`)].join(
1331
+ ", "
1332
+ );
1333
+ return apiKey.buckets.length ? "none" : "all";
1150
1334
  }
1151
1335
  function apiKeyFields(apiKey) {
1152
1336
  return fields([
@@ -1156,6 +1340,7 @@ function apiKeyFields(apiKey) {
1156
1340
  ["Ends in", apiKey.last4],
1157
1341
  ["Access", keyAccess(apiKey)],
1158
1342
  ["Buckets", keyBuckets(apiKey)],
1343
+ ["Databases", keyDatabases(apiKey)],
1159
1344
  ["Expires", when(apiKey.expires_at)],
1160
1345
  ["Last used", when(apiKey.last_used_at)],
1161
1346
  ["Created", apiKey.created_at]
@@ -1171,7 +1356,17 @@ async function apiKeyListCommand(deps, options = {}) {
1171
1356
  options.json,
1172
1357
  page,
1173
1358
  (keys) => keys.length ? table(
1174
- ["ID", "NAME", "MEMBER", "ENDS IN", "ACCESS", "BUCKETS", "EXPIRES", "LAST USED"],
1359
+ [
1360
+ "ID",
1361
+ "NAME",
1362
+ "MEMBER",
1363
+ "ENDS IN",
1364
+ "ACCESS",
1365
+ "BUCKETS",
1366
+ "DATABASES",
1367
+ "EXPIRES",
1368
+ "LAST USED"
1369
+ ],
1175
1370
  keys.map((apiKey) => [
1176
1371
  apiKey.id,
1177
1372
  apiKey.name,
@@ -1179,6 +1374,7 @@ async function apiKeyListCommand(deps, options = {}) {
1179
1374
  apiKey.last4,
1180
1375
  keyAccess(apiKey),
1181
1376
  keyBuckets(apiKey),
1377
+ keyDatabases(apiKey),
1182
1378
  when(apiKey.expires_at),
1183
1379
  when(apiKey.last_used_at)
1184
1380
  ])
@@ -1193,6 +1389,8 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
1193
1389
  member,
1194
1390
  read_only: options.readOnly,
1195
1391
  buckets: options.bucket,
1392
+ databases: options.database,
1393
+ database_prefixes: options.databasePrefix,
1196
1394
  expires_in: options.expiresIn
1197
1395
  });
1198
1396
  print(
@@ -1312,6 +1510,157 @@ async function bucketDeleteCommand(deps, name, options = {}) {
1312
1510
  `Bucket ${bucket.name} is ${bucket.state}; its streams are being purged.`
1313
1511
  );
1314
1512
  }
1513
+ function databaseAccess(database) {
1514
+ return database.public_read ? "public" : "private";
1515
+ }
1516
+ function databaseSize(database) {
1517
+ const { log, snapshot } = database.stored_bytes;
1518
+ if (log === null && snapshot === null) return "unknown";
1519
+ return `${bytes(log)} log, ${bytes(snapshot, "no")} snapshot`;
1520
+ }
1521
+ function databaseOwner(database) {
1522
+ return database.owner_id ? `${database.kind} ${database.owner_id}` : database.kind;
1523
+ }
1524
+ function databaseFields(database) {
1525
+ return fields([
1526
+ ["Name", database.name],
1527
+ ["Kind", databaseOwner(database)],
1528
+ ["Region", database.region],
1529
+ ["State", database.state],
1530
+ ["Read access", databaseAccess(database)],
1531
+ ["URL", database.url],
1532
+ ["Incarnation", database.incarnation ?? "none yet"],
1533
+ ["Last write", when(database.last_write_at)],
1534
+ ["Stored", databaseSize(database)],
1535
+ ["Created", `${database.created_at} by ${database.created_by}`]
1536
+ ]);
1537
+ }
1538
+ function noticeUnavailable(deps, regions, what) {
1539
+ if (regions.length)
1540
+ deps.notice(
1541
+ `Could not read ${what} in ${regions.join(", ")}; they show as unknown or are missing.`
1542
+ );
1543
+ }
1544
+ async function databaseListCommand(deps, options = {}) {
1545
+ const page = await deps.api.listDatabases(
1546
+ await deps.auth.apiKey(),
1547
+ { limit: options.limit, cursor: options.cursor },
1548
+ options.prefix
1549
+ );
1550
+ printPage(
1551
+ deps,
1552
+ options.json,
1553
+ page,
1554
+ (databases) => databases.length ? table(
1555
+ ["NAME", "KIND", "REGION", "STATE", "READ ACCESS", "LAST WRITE", "STORED", "URL"],
1556
+ databases.map((database) => [
1557
+ database.name,
1558
+ databaseOwner(database),
1559
+ database.region,
1560
+ database.state,
1561
+ databaseAccess(database),
1562
+ when(database.last_write_at),
1563
+ databaseSize(database),
1564
+ database.url
1565
+ ])
1566
+ ) : "No databases."
1567
+ );
1568
+ noticeUnavailable(deps, page.unavailable_regions, "sizes");
1569
+ }
1570
+ async function databaseCreateCommand(deps, name, options = {}) {
1571
+ const database = await deps.api.createDatabase(
1572
+ await deps.auth.apiKey(),
1573
+ { name, region: options.region },
1574
+ options.idempotencyKey ?? randomUUID2()
1575
+ );
1576
+ print(
1577
+ deps,
1578
+ options.json,
1579
+ database,
1580
+ [
1581
+ 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.`,
1582
+ databaseFields(database)
1583
+ ].join("\n")
1584
+ );
1585
+ }
1586
+ async function databaseShowCommand(deps, name, options = {}) {
1587
+ const database = await deps.api.getDatabase(await deps.auth.apiKey(), name);
1588
+ print(deps, options.json, database, databaseFields(database));
1589
+ }
1590
+ async function databaseUpdateCommand(deps, name, options = {}) {
1591
+ if (options.public === options.private)
1592
+ throw new Error("Pass exactly one of --public and --private.");
1593
+ const database = await deps.api.updateDatabase(await deps.auth.apiKey(), name, {
1594
+ public_read: options.public === true
1595
+ });
1596
+ print(
1597
+ deps,
1598
+ options.json,
1599
+ database,
1600
+ 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.`
1601
+ );
1602
+ }
1603
+ async function databaseDeleteCommand(deps, name, options = {}) {
1604
+ await confirm(
1605
+ deps,
1606
+ options.yes,
1607
+ `delete database ${name}`,
1608
+ () => Promise.resolve(
1609
+ `Delete database ${name} and everything in it? This cannot be undone. Type the database's name to confirm:`
1610
+ ),
1611
+ name
1612
+ );
1613
+ const database = await deps.api.deleteDatabase(await deps.auth.apiKey(), name);
1614
+ print(
1615
+ deps,
1616
+ options.json,
1617
+ database,
1618
+ 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.`
1619
+ );
1620
+ }
1621
+ function databaseUsageSort(value) {
1622
+ if (value === "-list_value" || value === "list_value") return "-list_value";
1623
+ if (value === "name") return "name";
1624
+ throw new Error("--sort is list_value or name.");
1625
+ }
1626
+ async function databaseUsageCommand(deps, options = {}) {
1627
+ const usage = await deps.api.listDatabaseUsage(
1628
+ await deps.auth.apiKey(),
1629
+ { limit: options.limit, cursor: options.cursor },
1630
+ { period: options.period, sort: options.sort, prefix: options.prefix }
1631
+ );
1632
+ if (options.json) deps.output(JSON.stringify(usage));
1633
+ else
1634
+ deps.output(
1635
+ [
1636
+ `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")})`,
1637
+ usage.data.length ? table(
1638
+ [
1639
+ "NAME",
1640
+ "REGION",
1641
+ "STATE",
1642
+ "WRITE UNITS",
1643
+ "LOG GIB-MONTHS",
1644
+ "SNAPSHOT GIB-MONTHS",
1645
+ "LIST VALUE"
1646
+ ],
1647
+ usage.data.map((row) => [
1648
+ row.name,
1649
+ row.region,
1650
+ row.state,
1651
+ row.write_units,
1652
+ row.retention_gib_months,
1653
+ row.snapshot_gib_months ?? "",
1654
+ usd(row.list_value_micros)
1655
+ ])
1656
+ ) : "No database usage.",
1657
+ `Other metered usage: ${usd(usage.other.list_value_micros)}`,
1658
+ "Charges, after the allowance and credit, are in `tonbo usage show`."
1659
+ ].join("\n")
1660
+ );
1661
+ if (usage.next_cursor) deps.notice(`More results: run again with --cursor ${usage.next_cursor}`);
1662
+ noticeUnavailable(deps, usage.unavailable_regions, "usage");
1663
+ }
1315
1664
  async function usageShowCommand(deps, options = {}) {
1316
1665
  const usage = await deps.api.getUsage(await deps.auth.apiKey(), options.period);
1317
1666
  print(
@@ -1336,60 +1685,6 @@ async function usageShowCommand(deps, options = {}) {
1336
1685
  );
1337
1686
  }
1338
1687
 
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
1688
  // src/prompt.ts
1394
1689
  import select from "@inquirer/select";
1395
1690
  import { createInterface } from "node:readline/promises";
@@ -1419,18 +1714,6 @@ async function terminalAsk(question) {
1419
1714
  var packageVersion = JSON.parse(
1420
1715
  readFileSync(new URL("../../package.json", import.meta.url), "utf8")
1421
1716
  );
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
1717
  function createDependencies() {
1435
1718
  const api = apiOrigin();
1436
1719
  const credentials = new FileCredentialStore();
@@ -1458,6 +1741,7 @@ function operationOptions(command, operationId) {
1458
1741
  command.option("--cursor <cursor>", "continue from the cursor a previous page printed");
1459
1742
  return command.option("--json", "print the API data as JSON");
1460
1743
  }
1744
+ var repeated = (value, previous = []) => [...previous, value];
1461
1745
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
1462
1746
  function createProgram(dependencies = createDependencies) {
1463
1747
  const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
@@ -1513,7 +1797,15 @@ function createProgram(dependencies = createDependencies) {
1513
1797
  ).option("--read-only", "narrow the key to read-only actions, for its whole life").option(
1514
1798
  "--bucket <name>",
1515
1799
  "narrow the key to this bucket, for its whole life; repeat for several",
1516
- (value, previous = []) => [...previous, value]
1800
+ repeated
1801
+ ).option(
1802
+ "--database <name>",
1803
+ "narrow the key to this database, for its whole life; repeat for several; never with --bucket",
1804
+ repeated
1805
+ ).option(
1806
+ "--database-prefix <prefix>",
1807
+ "narrow the key to the databases whose name starts with this, for its whole life; repeat for several; never with --bucket",
1808
+ repeated
1517
1809
  ).addOption(
1518
1810
  new Option("--expires-in <period>", "when the key expires; never by default").choices(
1519
1811
  apiKeyExpirations
@@ -1563,6 +1855,62 @@ function createProgram(dependencies = createDependencies) {
1563
1855
  ).action(
1564
1856
  async (name, options) => bucketDeleteCommand(dependencies(), name, options)
1565
1857
  );
1858
+ const database = program.command("database").description("the account's databases");
1859
+ operationOptions(
1860
+ database.command("list").description("list the databases in every region").option("--prefix <prefix>", "only databases whose name starts with this"),
1861
+ "listDatabases"
1862
+ ).action(
1863
+ async (options) => databaseListCommand(dependencies(), options)
1864
+ );
1865
+ operationOptions(
1866
+ database.command("create <name>").description("create a database; its region is fixed for its life").option(
1867
+ "--region <region>",
1868
+ "the database's region (see tonbo region list); the database default region by default"
1869
+ ).option(
1870
+ "--idempotency-key <key>",
1871
+ "reuse the key of an unconfirmed creation to get the database it created"
1872
+ ),
1873
+ "createDatabase"
1874
+ ).action(
1875
+ async (name, options) => databaseCreateCommand(dependencies(), name, options)
1876
+ );
1877
+ operationOptions(
1878
+ database.command("show <name>").description("show a database, its URL and size"),
1879
+ "getDatabase"
1880
+ ).action(
1881
+ async (name, options) => databaseShowCommand(dependencies(), name, options)
1882
+ );
1883
+ operationOptions(
1884
+ 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")),
1885
+ "updateDatabase"
1886
+ ).action(
1887
+ async (name, options) => databaseUpdateCommand(dependencies(), name, options)
1888
+ );
1889
+ operationOptions(
1890
+ database.command("delete <name>").description("delete a database and everything in it").option("--yes", "delete without asking; required when stdin is not a terminal"),
1891
+ "deleteDatabase"
1892
+ ).action(
1893
+ async (name, options) => databaseDeleteCommand(dependencies(), name, options)
1894
+ );
1895
+ operationOptions(
1896
+ database.command("usage").description("each database's usage of one month at list price, before allowance and credit").option(
1897
+ "--period <month>",
1898
+ "the calendar month in UTC, as YYYY-MM; the current one by default"
1899
+ ).option(
1900
+ "--sort <order>",
1901
+ "list_value (highest first, the default) or name",
1902
+ (value) => {
1903
+ try {
1904
+ return databaseUsageSort(value);
1905
+ } catch (error) {
1906
+ throw new InvalidArgumentError(error.message);
1907
+ }
1908
+ }
1909
+ ).option("--prefix <prefix>", "only databases whose name starts with this"),
1910
+ "listDatabaseUsage"
1911
+ ).action(
1912
+ async (options) => databaseUsageCommand(dependencies(), options)
1913
+ );
1566
1914
  operationOptions(
1567
1915
  program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
1568
1916
  "--period <month>",