@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.
- package/README.md +13 -7
- package/dist/bin/tonbo.js +850 -502
- 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/
|
|
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)
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
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)
|
|
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)
|
|
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)
|
|
394
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
579
|
-
|
|
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
|
-
|
|
586
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
599
|
-
|
|
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
|
-
|
|
605
|
-
|
|
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
|
-
|
|
644
|
-
|
|
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
|
-
|
|
656
|
-
|
|
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
|
-
|
|
704
|
-
return
|
|
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
|
|
788
|
-
|
|
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
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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/
|
|
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
|
-
|
|
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")
|
|
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))
|
|
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")
|
|
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))
|
|
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")
|
|
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")
|
|
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/
|
|
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
|
-
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
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")
|
|
989
|
-
|
|
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))
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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>",
|