@tonbo/cli 0.11.0 → 0.12.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 +8 -3
- package/dist/bin/tonbo.js +2050 -1029
- package/package.json +1 -1
package/dist/bin/tonbo.js
CHANGED
|
@@ -4,185 +4,6 @@
|
|
|
4
4
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
6
|
|
|
7
|
-
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
8
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
-
import { createServer } from "node:http";
|
|
10
|
-
import { execFile } from "node:child_process";
|
|
11
|
-
import os2 from "node:os";
|
|
12
|
-
import { promisify } from "node:util";
|
|
13
|
-
|
|
14
|
-
// ../../packages/tonbo-cli-core/dist/src/callback-page.js
|
|
15
|
-
var WORDMARK = [
|
|
16
|
-
"\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2580\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588",
|
|
17
|
-
"\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
|
|
18
|
-
"\u2588\u2588\u2588\u2584\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2584\u2588\u2584\u2588\u2588\u2584\u2584\u2588\u2588\u2588\u2588\u2584\u2584\u2588"
|
|
19
|
-
].join("\n");
|
|
20
|
-
var OUTCOMES = {
|
|
21
|
-
complete: {
|
|
22
|
-
status: 200,
|
|
23
|
-
title: "You are signed in.",
|
|
24
|
-
body: "Return to your terminal. This tab can be closed."
|
|
25
|
-
},
|
|
26
|
-
denied: {
|
|
27
|
-
status: 400,
|
|
28
|
-
title: "Login was not completed.",
|
|
29
|
-
body: "Nothing was authorized. Return to your terminal to try again."
|
|
30
|
-
},
|
|
31
|
-
invalid: {
|
|
32
|
-
// The state did not match, the code was missing, or the path was wrong.
|
|
33
|
-
// Say that it could not be verified rather than which check failed: the
|
|
34
|
-
// person who can read this page is not necessarily the one who started
|
|
35
|
-
// the login.
|
|
36
|
-
status: 400,
|
|
37
|
-
title: "This callback could not be verified.",
|
|
38
|
-
body: "Return to your terminal and start the login again."
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
function renderCallbackPage(outcome) {
|
|
42
|
-
const { title, body } = OUTCOMES[outcome];
|
|
43
|
-
return `<!doctype html>
|
|
44
|
-
<html lang="en">
|
|
45
|
-
<head>
|
|
46
|
-
<meta charset="utf-8">
|
|
47
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
48
|
-
<meta name="robots" content="noindex">
|
|
49
|
-
<title>Tonbo CLI</title>
|
|
50
|
-
<style>
|
|
51
|
-
:root {
|
|
52
|
-
--paper: #f3f2ee;
|
|
53
|
-
--ink: #1d1b17;
|
|
54
|
-
--rule: #c9c5ba;
|
|
55
|
-
--quiet: #5c564a;
|
|
56
|
-
--signal: #d64a03;
|
|
57
|
-
}
|
|
58
|
-
@media (prefers-color-scheme: dark) {
|
|
59
|
-
:root {
|
|
60
|
-
--paper: #1d1b17;
|
|
61
|
-
--ink: #f3f2ee;
|
|
62
|
-
--rule: #3a362e;
|
|
63
|
-
--quiet: #a49e8f;
|
|
64
|
-
--signal: #f08b4b;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
* { box-sizing: border-box; }
|
|
68
|
-
body {
|
|
69
|
-
margin: 0;
|
|
70
|
-
min-height: 100vh;
|
|
71
|
-
display: grid;
|
|
72
|
-
grid-template-rows: auto 1fr;
|
|
73
|
-
padding: 0 clamp(20px, 5vw, 72px);
|
|
74
|
-
background: var(--paper);
|
|
75
|
-
color: var(--ink);
|
|
76
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
77
|
-
-webkit-font-smoothing: antialiased;
|
|
78
|
-
}
|
|
79
|
-
header { padding: 28px 0; }
|
|
80
|
-
/* Same metrics as .auth-wordmark in the app: the glyphs are the ground
|
|
81
|
-
and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
|
|
82
|
-
what makes the rows meet without seams. Iosevka is not available here,
|
|
83
|
-
so it falls back to the platform monospace. */
|
|
84
|
-
pre.wordmark {
|
|
85
|
-
margin: 0;
|
|
86
|
-
font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
87
|
-
font-size: 8px;
|
|
88
|
-
font-weight: 400;
|
|
89
|
-
line-height: 10px;
|
|
90
|
-
letter-spacing: 0;
|
|
91
|
-
white-space: pre;
|
|
92
|
-
color: var(--signal);
|
|
93
|
-
transform: scaleY(0.8);
|
|
94
|
-
transform-origin: left top;
|
|
95
|
-
}
|
|
96
|
-
main {
|
|
97
|
-
display: flex;
|
|
98
|
-
flex-direction: column;
|
|
99
|
-
justify-content: center;
|
|
100
|
-
max-width: 46ch;
|
|
101
|
-
padding-bottom: 12vh;
|
|
102
|
-
}
|
|
103
|
-
h1 {
|
|
104
|
-
margin: 0;
|
|
105
|
-
font-size: 26px;
|
|
106
|
-
font-weight: 300;
|
|
107
|
-
line-height: 1.16;
|
|
108
|
-
letter-spacing: -0.018em;
|
|
109
|
-
}
|
|
110
|
-
p {
|
|
111
|
-
margin: 14px 0 0;
|
|
112
|
-
font-size: 16px;
|
|
113
|
-
line-height: 1.55;
|
|
114
|
-
color: var(--quiet);
|
|
115
|
-
}
|
|
116
|
-
hr {
|
|
117
|
-
width: 40px;
|
|
118
|
-
margin: 28px 0 0;
|
|
119
|
-
border: 0;
|
|
120
|
-
border-top: 1px solid var(--rule);
|
|
121
|
-
}
|
|
122
|
-
</style>
|
|
123
|
-
</head>
|
|
124
|
-
<body>
|
|
125
|
-
<header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
|
|
126
|
-
<main>
|
|
127
|
-
<h1>${title}</h1>
|
|
128
|
-
<p>${body}</p>
|
|
129
|
-
<hr>
|
|
130
|
-
</main>
|
|
131
|
-
</body>
|
|
132
|
-
</html>
|
|
133
|
-
`;
|
|
134
|
-
}
|
|
135
|
-
function callbackResponse(outcome) {
|
|
136
|
-
return {
|
|
137
|
-
status: OUTCOMES[outcome].status,
|
|
138
|
-
headers: {
|
|
139
|
-
"content-type": "text/html; charset=utf-8",
|
|
140
|
-
"cache-control": "no-store"
|
|
141
|
-
},
|
|
142
|
-
body: renderCallbackPage(outcome)
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// ../../packages/tonbo-cli-core/dist/src/http.js
|
|
147
|
-
var HttpError = class extends Error {
|
|
148
|
-
status;
|
|
149
|
-
body;
|
|
150
|
-
requestId;
|
|
151
|
-
constructor(message, status, body, requestId) {
|
|
152
|
-
super(message);
|
|
153
|
-
this.status = status;
|
|
154
|
-
this.body = body;
|
|
155
|
-
this.requestId = requestId;
|
|
156
|
-
}
|
|
157
|
-
/** The problem `code` of a Management API refusal, such as `unauthenticated`. */
|
|
158
|
-
get code() {
|
|
159
|
-
const record = this.body && typeof this.body === "object" ? this.body : {};
|
|
160
|
-
const code = record.code;
|
|
161
|
-
return typeof code === "string" ? code : null;
|
|
162
|
-
}
|
|
163
|
-
};
|
|
164
|
-
async function requestJson(fetcher, url, init = {}) {
|
|
165
|
-
const response = await fetcher(url, init);
|
|
166
|
-
const body = await response.json().catch(() => null);
|
|
167
|
-
if (response.ok)
|
|
168
|
-
return body;
|
|
169
|
-
const record = body && typeof body === "object" ? body : {};
|
|
170
|
-
const reason = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
|
|
171
|
-
const requestId = response.headers.get("x-request-id") ?? (typeof record.request_id === "string" ? record.request_id : null);
|
|
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}`) : [];
|
|
173
|
-
const message = [
|
|
174
|
-
reason ?? `Request failed with HTTP ${response.status}.`,
|
|
175
|
-
...issues.length ? [`(${issues.join(", ")})`] : []
|
|
176
|
-
].join(" ");
|
|
177
|
-
throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
181
|
-
import { randomUUID } from "node:crypto";
|
|
182
|
-
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
183
|
-
import os from "node:os";
|
|
184
|
-
import path from "node:path";
|
|
185
|
-
|
|
186
7
|
// ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
|
|
187
8
|
var agentNameContract = {
|
|
188
9
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
@@ -512,7 +333,9 @@ var managementOperations = {
|
|
|
512
333
|
"method": "GET",
|
|
513
334
|
"path": "/usage/today",
|
|
514
335
|
"pathParams": [],
|
|
515
|
-
"query": [
|
|
336
|
+
"query": [
|
|
337
|
+
"tz"
|
|
338
|
+
],
|
|
516
339
|
"idempotencyKey": false,
|
|
517
340
|
"cli": "tonbo usage today"
|
|
518
341
|
},
|
|
@@ -1056,7 +879,7 @@ var apiKeyExpirations = [
|
|
|
1056
879
|
];
|
|
1057
880
|
var credentialsContract = {
|
|
1058
881
|
"version": 1,
|
|
1059
|
-
"description": "
|
|
882
|
+
"description": "Legacy from ADR-0018: the per-account CLI login of @tonbo/cli 0.11 and @tonbo/yield 0.3. From @tonbo/cli 0.12 and @tonbo/yield 0.4 a login is one session of the person in login.json (credentials-v2.json); clients read this file only as the fallback when login.json is missing, and the per-account keys it holds are retired when those CLIs ship. Before that: the login `tonbo login` (and `yield login`) saves: a personal key of one account at the top level, the login made last, and, beside it, every account's login of this device in `accounts` with the device's `login_secret` (ADR-0016, \"Credentials\", PR 4b-ii). @tonbo/cli-core writes and reads it; the Tonbo SDK (@tonbo/sdk and tonbo-sdk, contracts/sdk/v1.json) reads it as its last key source, and every other reader follows `reader` and reads the top level alone.",
|
|
1060
883
|
"path": {
|
|
1061
884
|
"description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
|
|
1062
885
|
"base_env": "XDG_CONFIG_HOME",
|
|
@@ -1095,11 +918,11 @@ var credentialsContract = {
|
|
|
1095
918
|
"login_hint": "tonbo login"
|
|
1096
919
|
},
|
|
1097
920
|
"accounts": {
|
|
1098
|
-
"description": "One CLI login key per account (ADR-0016 R7 (a)): a daemon that publishes into an organization holds that organization's key beside the personal account's, since a key reaches only its own account. The fields are additive and optional, so a version 1 reader that knows only the top level keeps working, and the format stays version 1.",
|
|
921
|
+
"description": "Retiring with ADR-0018: a session reaches every account through per-account tokens, so no client writes per-account keys once @tonbo/cli 0.12 and @tonbo/yield 0.4 ship. One CLI login key per account (ADR-0016 R7 (a)): a daemon that publishes into an organization holds that organization's key beside the personal account's, since a key reaches only its own account. The fields are additive and optional, so a version 1 reader that knows only the top level keeps working, and the format stays version 1.",
|
|
1099
922
|
"top_level": "The login made last, by `tonbo login` or `yield login`: the account `tonbo` acts in. A login made only to publish into an organization (`yield connect --account`) is added to `accounts` and leaves the top level alone.",
|
|
1100
923
|
"entries": "`accounts` maps an account id to that account's login on this device, the top level's account included. `kind` and `name` are the redemption's answer; a file written before them, or by a server that does not answer them, has the top level alone, whose kind a client asks the API for.",
|
|
1101
924
|
"personal": "The entry whose kind is `user` is the person's personal account's login: it alone captures raw sessions and registers the device (ADR-0016 W-10, W-14).",
|
|
1102
|
-
"login_secret": "One random per-device secret, 32 bytes base64url, made at the first login and kept: it is sent only in the redeem POST, beside the PKCE verifier, never in the /cli/login URL, and User Center keeps only the suffix it derives from it, in an organization key's name `Yield CLI \xB7 <8 hex>`. `tonbo` and `yield` share it through this file, and so share one key per account. A lost file sends a new secret and gets a new suffix; the old key stays until it is revoked or idles out."
|
|
925
|
+
"login_secret": "Retiring with ADR-0018. One random per-device secret, 32 bytes base64url, made at the first login and kept: it is sent only in the redeem POST, beside the PKCE verifier, never in the /cli/login URL, and User Center keeps only the suffix it derives from it, in an organization key's name `Yield CLI \xB7 <8 hex>`. `tonbo` and `yield` share it through this file, and so share one key per account. A lost file sends a new secret and gets a new suffix; the old key stays until it is revoked or idles out."
|
|
1103
926
|
},
|
|
1104
927
|
"current_version": 1,
|
|
1105
928
|
"schema": {
|
|
@@ -1326,864 +1149,1940 @@ var credentialsContract = {
|
|
|
1326
1149
|
]
|
|
1327
1150
|
}
|
|
1328
1151
|
};
|
|
1329
|
-
var
|
|
1330
|
-
"
|
|
1331
|
-
"
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1152
|
+
var loginContract = {
|
|
1153
|
+
"version": 2,
|
|
1154
|
+
"description": "The CLI login `tonbo login` (and `yield login`) saves from @tonbo/cli 0.12 and @tonbo/yield 0.4 (ADR-0018): one session of the person per computer, tied to the computer's device id, that reaches every account the person belongs to only through per-account access tokens exchanged at POST /api/iam/token (contracts/iam/access-token-v1.json `cli_session`). It is a new file beside credentials-v1.json's, not a version 2 of it, so a reader of version 1 never sees it and never overwrites it. @tonbo/cli-core writes and reads it; the Tonbo SDK (contracts/sdk/v1.json) reads it before the legacy file.",
|
|
1155
|
+
"path": {
|
|
1156
|
+
"description": "`${XDG_CONFIG_HOME}/tonbo/login.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/login.json`.",
|
|
1157
|
+
"base_env": "XDG_CONFIG_HOME",
|
|
1158
|
+
"fallback_base": [
|
|
1159
|
+
"$HOME",
|
|
1160
|
+
".config"
|
|
1161
|
+
],
|
|
1162
|
+
"relative": [
|
|
1163
|
+
"tonbo",
|
|
1164
|
+
"login.json"
|
|
1165
|
+
]
|
|
1166
|
+
},
|
|
1167
|
+
"permissions": {
|
|
1168
|
+
"description": "POSIX only; a reader on Windows skips the mode checks. The same rules as credentials-v1.json.",
|
|
1169
|
+
"directory_mode": "0700",
|
|
1170
|
+
"file_mode": "0600",
|
|
1171
|
+
"writer": "The writer creates the directory 0700, writes a temporary file 0600 beside the target, syncs it and renames it over the target, so a crash never leaves a partial login. It refuses to replace or remove a symlink or anything that is not a regular file."
|
|
1172
|
+
},
|
|
1173
|
+
"key_sources": {
|
|
1174
|
+
"description": "Where a client takes its credential, first match wins. A library takes an explicit option first; the CLIs have none. `env` is an API key of one account (TONBO_API_KEY, contracts/iam/access-token-v1.json `personal_key`), which binds the client to the key's account. `file` is this login, then the legacy file of credentials-v1.json, which a client reads only when this file is missing.",
|
|
1175
|
+
"order": [
|
|
1176
|
+
"option",
|
|
1177
|
+
"env",
|
|
1178
|
+
"file"
|
|
1179
|
+
],
|
|
1180
|
+
"env": "TONBO_API_KEY",
|
|
1181
|
+
"files": [
|
|
1182
|
+
"login.json",
|
|
1183
|
+
"credentials.json"
|
|
1184
|
+
]
|
|
1185
|
+
},
|
|
1186
|
+
"account": {
|
|
1187
|
+
"description": "Which account a client acts in with this login. A session reaches no account by itself: every request names one, and the exchange checks the person's role there at that moment.",
|
|
1188
|
+
"order": [
|
|
1189
|
+
"option",
|
|
1190
|
+
"env",
|
|
1191
|
+
"default_account_id",
|
|
1192
|
+
"personal_account_id"
|
|
1193
|
+
],
|
|
1194
|
+
"option": "The command's `--account <name|id>` or the library's `account` option.",
|
|
1195
|
+
"env": "TONBO_ACCOUNT",
|
|
1196
|
+
"env_description": "An account's name or id. Beside TONBO_API_KEY it must name the key's own account, or the client refuses before it calls anything.",
|
|
1197
|
+
"names": "A name is resolved through `accounts`; when it misses, or the exchange answers account_not_found, the client refreshes `accounts` from GET /api/iam/session and tries once more. `accounts` is a cache for names and offline display, never authority."
|
|
1198
|
+
},
|
|
1199
|
+
"device_file": {
|
|
1200
|
+
"description": "The computer's device id (ADR-0015's device id), kept in the state directory and never written to login.json, which holds only its SHA-256 (`device_id_sha256`). So a login.json copied without the state directory (a synced or published `~/.config`, a backup restored onto another computer) carries no device id, and an exchange that presents any other id is refused with cli_session_device_mismatch. @tonbo/cli-core makes it at the first login of either CLI and re-makes it when the saved hash of the operating system's machine id differs from the machine's (a cloned disk). A reader that only reads it (the SDK) takes `device_id` and never writes the file.",
|
|
1201
|
+
"base_env": "XDG_STATE_HOME",
|
|
1202
|
+
"fallback_base": [
|
|
1203
|
+
"$HOME",
|
|
1204
|
+
".local",
|
|
1205
|
+
"state"
|
|
1206
|
+
],
|
|
1207
|
+
"relative": [
|
|
1208
|
+
"tonbo-yield",
|
|
1209
|
+
"device.json"
|
|
1210
|
+
],
|
|
1211
|
+
"schema": {
|
|
1212
|
+
"type": "object",
|
|
1213
|
+
"required": [
|
|
1214
|
+
"version",
|
|
1215
|
+
"device_id",
|
|
1216
|
+
"machine_id_sha256"
|
|
1217
|
+
],
|
|
1218
|
+
"additionalProperties": true,
|
|
1219
|
+
"properties": {
|
|
1220
|
+
"version": {
|
|
1221
|
+
"const": 1
|
|
1222
|
+
},
|
|
1223
|
+
"device_id": {
|
|
1224
|
+
"type": "string",
|
|
1225
|
+
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
1226
|
+
"description": "A random UUID made at the first run."
|
|
1227
|
+
},
|
|
1228
|
+
"machine_id_sha256": {
|
|
1229
|
+
"type": [
|
|
1230
|
+
"string",
|
|
1231
|
+
"null"
|
|
1232
|
+
],
|
|
1233
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
1234
|
+
"description": "The SHA-256 of the operating system's machine id (macOS IOPlatformUUID, Linux /etc/machine-id), or null when it could not be read."
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
"missing": "No file, or one that does not match `schema`: a reader that does not make it treats the login as unusable on this computer and asks the person to run `tonbo login`."
|
|
1239
|
+
},
|
|
1240
|
+
"reader": {
|
|
1241
|
+
"missing": "No file: read credentials.json under credentials-v1.json; with neither, ask the person to run `tonbo login`.",
|
|
1242
|
+
"refuse_symlink": true,
|
|
1243
|
+
"refuse_non_regular_file": true,
|
|
1244
|
+
"refuse_mode_bits": "0077",
|
|
1245
|
+
"refused": "A symlink, a non-regular file, or a file any group or other permission bit is set on is an error and is never read; do not fall back to another source.",
|
|
1246
|
+
"unknown_version": "A `version` other than `current_version`, or content that does not match `schema`, is unusable: ask the person to run `tonbo login`. It is never migrated, and the legacy file is not read in its place.",
|
|
1247
|
+
"expired": "A login whose `session.expires_at` is at or before now is expired: ask the person to run `tonbo login`. The server also ends a session 30 days after its last use and whenever it is revoked; a client learns that from a 401 at the exchange.",
|
|
1248
|
+
"device": "Every exchange sends the `device_id` of the computer's device file (`device_file`), and only when its SHA-256 (lowercase hex of the id's UTF-8 text) equals the login's `device_id_sha256`. Without a usable device file, or with another device's, the login is unusable on this computer: ask the person to run `tonbo login`, never send a mismatched id. A writer never puts the device id itself in login.json.",
|
|
1249
|
+
"login_hint": "tonbo login"
|
|
1250
|
+
},
|
|
1251
|
+
"current_version": 2,
|
|
1252
|
+
"schema": {
|
|
1253
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
1254
|
+
"$id": "https://tonbo.dev/contracts/cli/credentials-v2.json",
|
|
1255
|
+
"title": "Tonbo CLI login session",
|
|
1256
|
+
"type": "object",
|
|
1257
|
+
"required": [
|
|
1258
|
+
"version",
|
|
1259
|
+
"session",
|
|
1260
|
+
"user_id",
|
|
1261
|
+
"device_id_sha256",
|
|
1262
|
+
"personal_account_id"
|
|
1263
|
+
],
|
|
1264
|
+
"additionalProperties": true,
|
|
1265
|
+
"properties": {
|
|
1266
|
+
"version": {
|
|
1267
|
+
"const": 2
|
|
1268
|
+
},
|
|
1269
|
+
"session": {
|
|
1270
|
+
"type": "object",
|
|
1271
|
+
"required": [
|
|
1272
|
+
"id",
|
|
1273
|
+
"secret",
|
|
1274
|
+
"expires_at"
|
|
1275
|
+
],
|
|
1276
|
+
"additionalProperties": true,
|
|
1277
|
+
"properties": {
|
|
1278
|
+
"id": {
|
|
1279
|
+
"type": "string",
|
|
1280
|
+
"minLength": 1,
|
|
1281
|
+
"description": "The session's id (a UUID)."
|
|
1282
|
+
},
|
|
1283
|
+
"secret": {
|
|
1284
|
+
"type": "string",
|
|
1285
|
+
"pattern": "^tbs_",
|
|
1286
|
+
"description": "The session's secret (contracts/iam/access-token-v1.json `cli_session.format`). It is presented only to POST /api/iam/token and /api/iam/session, never to a resource server."
|
|
1287
|
+
},
|
|
1288
|
+
"expires_at": {
|
|
1289
|
+
"type": "string",
|
|
1290
|
+
"format": "date-time",
|
|
1291
|
+
"description": "The session's absolute expiry, ISO 8601."
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
},
|
|
1295
|
+
"user_id": {
|
|
1296
|
+
"type": "string",
|
|
1297
|
+
"minLength": 1,
|
|
1298
|
+
"description": "The person the session belongs to (a UUID)."
|
|
1299
|
+
},
|
|
1300
|
+
"device_id_sha256": {
|
|
1301
|
+
"type": "string",
|
|
1302
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
1303
|
+
"description": "The SHA-256 (lowercase hex) of the device id the session is tied to. The id itself stays in the device file (`device_file`), so this file alone cannot be presented from another computer."
|
|
1304
|
+
},
|
|
1305
|
+
"personal_account_id": {
|
|
1306
|
+
"type": "string",
|
|
1307
|
+
"minLength": 1,
|
|
1308
|
+
"description": "The person's personal account (a UUID): the account of a command without --account, TONBO_ACCOUNT or a default."
|
|
1309
|
+
},
|
|
1310
|
+
"default_account_id": {
|
|
1311
|
+
"type": [
|
|
1312
|
+
"string",
|
|
1313
|
+
"null"
|
|
1314
|
+
],
|
|
1315
|
+
"minLength": 1,
|
|
1316
|
+
"description": "The account commands act in without --account or TONBO_ACCOUNT (`tonbo account use`); null or absent for the personal account."
|
|
1317
|
+
},
|
|
1318
|
+
"accounts": {
|
|
1319
|
+
"type": "object",
|
|
1320
|
+
"description": "The person's accounts as GET /api/iam/session last answered them, keyed by account id: a cache of names, never authority.",
|
|
1321
|
+
"additionalProperties": {
|
|
1322
|
+
"type": "object",
|
|
1323
|
+
"required": [
|
|
1324
|
+
"name",
|
|
1325
|
+
"kind"
|
|
1326
|
+
],
|
|
1327
|
+
"properties": {
|
|
1328
|
+
"name": {
|
|
1329
|
+
"type": "string",
|
|
1330
|
+
"minLength": 1,
|
|
1331
|
+
"description": "The account's name, which --account and TONBO_ACCOUNT take."
|
|
1332
|
+
},
|
|
1333
|
+
"kind": {
|
|
1334
|
+
"type": "string",
|
|
1335
|
+
"pattern": "^(user|organization)$",
|
|
1336
|
+
"description": "`user` for the personal account."
|
|
1337
|
+
},
|
|
1338
|
+
"role": {
|
|
1339
|
+
"type": [
|
|
1340
|
+
"string",
|
|
1341
|
+
"null"
|
|
1342
|
+
],
|
|
1343
|
+
"description": "The person's role when the cache was written; display only."
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
},
|
|
1350
|
+
"examples": {
|
|
1351
|
+
"usable": [
|
|
1352
|
+
{
|
|
1353
|
+
"version": 2,
|
|
1354
|
+
"session": {
|
|
1355
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1356
|
+
"secret": "tbs_secret",
|
|
1357
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1358
|
+
},
|
|
1359
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1360
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1361
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1362
|
+
},
|
|
1363
|
+
{
|
|
1364
|
+
"version": 2,
|
|
1365
|
+
"session": {
|
|
1366
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1367
|
+
"secret": "tbs_secret",
|
|
1368
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1369
|
+
},
|
|
1370
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1371
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1372
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001",
|
|
1373
|
+
"default_account_id": "10000000-0000-4000-8000-0000000000a1",
|
|
1374
|
+
"accounts": {
|
|
1375
|
+
"10000000-0000-4000-8000-000000000001": {
|
|
1376
|
+
"name": "sam",
|
|
1377
|
+
"kind": "user",
|
|
1378
|
+
"role": "owner"
|
|
1379
|
+
},
|
|
1380
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
1381
|
+
"name": "acme",
|
|
1382
|
+
"kind": "organization",
|
|
1383
|
+
"role": "member"
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
],
|
|
1388
|
+
"unusable": [
|
|
1389
|
+
{
|
|
1390
|
+
"version": 1,
|
|
1391
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
1392
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
1393
|
+
"key": "tbo_secret",
|
|
1394
|
+
"expires_at": null
|
|
1395
|
+
},
|
|
1396
|
+
{
|
|
1397
|
+
"version": 2,
|
|
1398
|
+
"session": {
|
|
1399
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1400
|
+
"secret": "tbo_secret",
|
|
1401
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1402
|
+
},
|
|
1403
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1404
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1405
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1406
|
+
},
|
|
1407
|
+
{
|
|
1408
|
+
"version": 2,
|
|
1409
|
+
"session": {
|
|
1410
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1411
|
+
"secret": "tbs_secret",
|
|
1412
|
+
"expires_at": null
|
|
1413
|
+
},
|
|
1414
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1415
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1416
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
"version": 2,
|
|
1420
|
+
"session": {
|
|
1421
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1422
|
+
"secret": "tbs_secret",
|
|
1423
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1424
|
+
},
|
|
1425
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1426
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
"version": 2,
|
|
1430
|
+
"session": {
|
|
1431
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1432
|
+
"secret": "tbs_secret",
|
|
1433
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1434
|
+
},
|
|
1435
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1436
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1437
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001",
|
|
1438
|
+
"accounts": {
|
|
1439
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
1440
|
+
"name": "acme",
|
|
1441
|
+
"kind": "team"
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
"version": 3,
|
|
1447
|
+
"session": {
|
|
1448
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1449
|
+
"secret": "tbs_secret",
|
|
1450
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1451
|
+
},
|
|
1452
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1453
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1454
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1455
|
+
}
|
|
1456
|
+
]
|
|
1457
|
+
}
|
|
1458
|
+
};
|
|
1459
|
+
var cliSessionContract = {
|
|
1460
|
+
"prefix": "tbs_",
|
|
1461
|
+
"grant": "cli_session",
|
|
1462
|
+
"grantParameter": "grant",
|
|
1463
|
+
"exchangePath": "/api/iam/token",
|
|
1464
|
+
"sessionPath": "/api/iam/session",
|
|
1465
|
+
"audiences": {
|
|
1466
|
+
"streams": "https://streams.tonbo.dev",
|
|
1467
|
+
"management": "https://api.tonbo.dev"
|
|
1468
|
+
},
|
|
1469
|
+
"idleDays": 30
|
|
1470
|
+
};
|
|
1471
|
+
var machineIdleActions = [
|
|
1472
|
+
"suspend",
|
|
1473
|
+
"release"
|
|
1474
|
+
];
|
|
1475
|
+
var machinePortAccess = [
|
|
1476
|
+
"private",
|
|
1477
|
+
"public"
|
|
1478
|
+
];
|
|
1479
|
+
var machineTimings = {
|
|
1480
|
+
"pollAfterSeconds": 2,
|
|
1481
|
+
"sleepPollAfterSeconds": 5,
|
|
1482
|
+
"wakeHoldSeconds": 25,
|
|
1483
|
+
"earlyReclaimSeconds": 120
|
|
1484
|
+
};
|
|
1485
|
+
|
|
1486
|
+
// ../../packages/tonbo-cli-core/dist/src/bearer.js
|
|
1487
|
+
var MANAGEMENT_AUDIENCE = cliSessionContract.audiences.management;
|
|
1488
|
+
function isAccountBearer(bearer) {
|
|
1489
|
+
return typeof bearer !== "string";
|
|
1490
|
+
}
|
|
1491
|
+
function managementBearer(bearer) {
|
|
1492
|
+
return isAccountBearer(bearer) ? bearer.token(MANAGEMENT_AUDIENCE) : Promise.resolve(bearer);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
// ../../packages/tonbo-cli-core/dist/src/access-token.js
|
|
1496
|
+
var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
|
|
1497
|
+
var GIT_AUDIENCE = "https://git.tonbo.dev";
|
|
1498
|
+
var AccessTokenError = class extends Error {
|
|
1499
|
+
status;
|
|
1500
|
+
retryAfterMs;
|
|
1501
|
+
code;
|
|
1502
|
+
constructor(message, status, retryAfterMs = null, code = null) {
|
|
1503
|
+
super(message);
|
|
1504
|
+
this.status = status;
|
|
1505
|
+
this.retryAfterMs = retryAfterMs;
|
|
1506
|
+
this.code = code;
|
|
1507
|
+
}
|
|
1508
|
+
get retryable() {
|
|
1509
|
+
return this.status === null || this.status === 429 || this.status >= 500;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
|
|
1513
|
+
// ../../packages/tonbo-cli-core/dist/src/http.js
|
|
1514
|
+
var HttpError = class extends Error {
|
|
1515
|
+
status;
|
|
1516
|
+
body;
|
|
1517
|
+
requestId;
|
|
1518
|
+
constructor(message, status, body, requestId) {
|
|
1519
|
+
super(message);
|
|
1520
|
+
this.status = status;
|
|
1521
|
+
this.body = body;
|
|
1522
|
+
this.requestId = requestId;
|
|
1523
|
+
}
|
|
1524
|
+
/** The problem `code` of a Management API refusal, such as `unauthenticated`. */
|
|
1525
|
+
get code() {
|
|
1526
|
+
const record = this.body && typeof this.body === "object" ? this.body : {};
|
|
1527
|
+
const code = record.code;
|
|
1528
|
+
return typeof code === "string" ? code : null;
|
|
1529
|
+
}
|
|
1530
|
+
};
|
|
1531
|
+
async function requestJson(fetcher, url, init = {}) {
|
|
1532
|
+
const response = await fetcher(url, init);
|
|
1533
|
+
const body = await response.json().catch(() => null);
|
|
1534
|
+
if (response.ok)
|
|
1535
|
+
return body;
|
|
1536
|
+
const record = body && typeof body === "object" ? body : {};
|
|
1537
|
+
const reason = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
|
|
1538
|
+
const requestId = response.headers.get("x-request-id") ?? (typeof record.request_id === "string" ? record.request_id : null);
|
|
1539
|
+
const issues = Array.isArray(record.errors) ? record.errors.filter((issue) => typeof issue?.field === "string" && typeof issue.code === "string").map((issue) => `${issue.field}: ${issue.code}`) : [];
|
|
1540
|
+
const message = [
|
|
1541
|
+
reason ?? `Request failed with HTTP ${response.status}.`,
|
|
1542
|
+
...issues.length ? [`(${issues.join(", ")})`] : []
|
|
1543
|
+
].join(" ");
|
|
1544
|
+
throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
// ../../packages/tonbo-cli-core/dist/src/api.js
|
|
1548
|
+
var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
|
|
1549
|
+
var DEFAULT_LOGIN_HINT2 = credentialsContract.reader.login_hint;
|
|
1550
|
+
var ExchangeRefusal = class extends HttpError {
|
|
1551
|
+
};
|
|
1552
|
+
async function presented(key) {
|
|
1553
|
+
try {
|
|
1554
|
+
return await managementBearer(key);
|
|
1555
|
+
} catch (error) {
|
|
1556
|
+
if (!(error instanceof AccessTokenError))
|
|
1557
|
+
throw error;
|
|
1558
|
+
throw new ExchangeRefusal(error.message, error.status ?? 503, { code: error.code }, null);
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
var ManagementApi = class {
|
|
1562
|
+
fetcher;
|
|
1563
|
+
apiOrigin;
|
|
1564
|
+
loginHint;
|
|
1565
|
+
pause;
|
|
1566
|
+
constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT2, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
|
|
1567
|
+
this.fetcher = fetcher;
|
|
1568
|
+
this.apiOrigin = apiOrigin2;
|
|
1569
|
+
this.loginHint = loginHint;
|
|
1570
|
+
this.pause = pause;
|
|
1571
|
+
}
|
|
1572
|
+
/** The answer's `data`. A refusal throws an HttpError naming the request id. */
|
|
1573
|
+
async call(operationId, key, request = {}) {
|
|
1574
|
+
return (await this.request(operationId, key, request)).data;
|
|
1575
|
+
}
|
|
1576
|
+
/** One page of a list operation: its `data` and `next_cursor`, with the
|
|
1577
|
+
* operation's other query parameters, if any. */
|
|
1578
|
+
page(operationId, key, { limit, cursor } = {}, query = {}) {
|
|
1579
|
+
return this.request(operationId, key, { query: { ...query, limit, cursor } });
|
|
1580
|
+
}
|
|
1581
|
+
async request(operationId, key, request, seen) {
|
|
1582
|
+
const attempt = async () => this.requestWith(operationId, await presented(key), request, seen);
|
|
1583
|
+
try {
|
|
1584
|
+
return await attempt();
|
|
1585
|
+
} catch (error) {
|
|
1586
|
+
if (!isAccountBearer(key) || !(error instanceof HttpError) || error.status !== 401 || error instanceof ExchangeRefusal)
|
|
1587
|
+
throw error;
|
|
1588
|
+
key.invalidate(MANAGEMENT_AUDIENCE);
|
|
1589
|
+
return attempt();
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
async requestWith(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }, seen) {
|
|
1593
|
+
const operation = managementOperations[operationId];
|
|
1594
|
+
const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1595
|
+
const value = params[name];
|
|
1596
|
+
if (value === void 0)
|
|
1597
|
+
throw new Error(`${operationId} needs the path parameter ${name}.`);
|
|
1598
|
+
return encodeURIComponent(value);
|
|
1599
|
+
});
|
|
1600
|
+
const search = new URLSearchParams();
|
|
1601
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1602
|
+
if (value === void 0)
|
|
1603
|
+
continue;
|
|
1604
|
+
if (!operation.query.includes(name))
|
|
1605
|
+
throw new Error(`${operationId} takes no query parameter ${name}.`);
|
|
1606
|
+
search.set(name, String(value));
|
|
1607
|
+
}
|
|
1608
|
+
if (operation.idempotencyKey && !idempotencyKey)
|
|
1609
|
+
throw new Error(`${operationId} needs an Idempotency-Key.`);
|
|
1610
|
+
const headers = { authorization: `Bearer ${key}` };
|
|
1611
|
+
if (body !== void 0)
|
|
1612
|
+
headers["content-type"] = "application/json";
|
|
1613
|
+
if (idempotencyKey)
|
|
1614
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
1615
|
+
if (ifMatch)
|
|
1616
|
+
headers["if-match"] = ifMatch;
|
|
1617
|
+
const queryString = search.toString();
|
|
1618
|
+
const url = `${this.apiOrigin}${managementApiBasePath}${path4}${queryString ? `?${queryString}` : ""}`;
|
|
1619
|
+
try {
|
|
1620
|
+
const fetcher = seen ? async (input, init) => {
|
|
1621
|
+
const response = await this.fetcher(input, init);
|
|
1622
|
+
seen(response);
|
|
1623
|
+
return response;
|
|
1624
|
+
} : this.fetcher;
|
|
1625
|
+
return await requestJson(fetcher, url, {
|
|
1626
|
+
method: operation.method,
|
|
1627
|
+
headers,
|
|
1628
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1629
|
+
});
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (error instanceof HttpError && error.status === 401)
|
|
1632
|
+
throw new HttpError(`${error.message} Run \`${this.loginHint}\`.`, error.status, error.body, error.requestId);
|
|
1633
|
+
throw error;
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
getAccount(key) {
|
|
1637
|
+
return this.call("getAccount", key);
|
|
1638
|
+
}
|
|
1639
|
+
listRegions(key) {
|
|
1640
|
+
return this.call("listRegions", key);
|
|
1641
|
+
}
|
|
1642
|
+
listMembers(key, page) {
|
|
1643
|
+
return this.page("listMembers", key, page);
|
|
1644
|
+
}
|
|
1645
|
+
listGitWorkspaces(key, page) {
|
|
1646
|
+
return this.page("listGitWorkspaces", key, page);
|
|
1647
|
+
}
|
|
1648
|
+
updateMember(key, subject, change) {
|
|
1649
|
+
return this.call("updateMember", key, { params: { subject }, body: change });
|
|
1650
|
+
}
|
|
1651
|
+
/** A replay with the same idempotency key returns the Agent the first
|
|
1652
|
+
* request created, so an unconfirmed creation is retried with that key. */
|
|
1653
|
+
createAgent(key, request, idempotencyKey) {
|
|
1654
|
+
return this.call("createAgent", key, {
|
|
1655
|
+
body: withoutUndefined(request),
|
|
1656
|
+
idempotencyKey
|
|
1657
|
+
}).catch(unconfirmed("Agent creation", idempotencyKey, "the same name, role and region"));
|
|
1658
|
+
}
|
|
1659
|
+
listApiKeys(key, page) {
|
|
1660
|
+
return this.page("listApiKeys", key, page);
|
|
1661
|
+
}
|
|
1662
|
+
/** Not idempotent: the secret is never stored, so a replay could not
|
|
1663
|
+
* return it, and a blind retry of a live name answers 409. */
|
|
1664
|
+
createApiKey(key, request) {
|
|
1665
|
+
return this.call("createApiKey", key, { body: withoutUndefined(request) });
|
|
1666
|
+
}
|
|
1667
|
+
getApiKey(key, keyId) {
|
|
1668
|
+
return this.call("getApiKey", key, { params: { key_id: keyId } });
|
|
1669
|
+
}
|
|
1670
|
+
revokeApiKey(key, keyId) {
|
|
1671
|
+
return this.call("revokeApiKey", key, {
|
|
1672
|
+
params: { key_id: keyId }
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
listBuckets(key, page) {
|
|
1676
|
+
return this.page("listBuckets", key, page);
|
|
1677
|
+
}
|
|
1678
|
+
/** A replay with the same idempotency key returns the bucket the first
|
|
1679
|
+
* request created. */
|
|
1680
|
+
createBucket(key, request, idempotencyKey) {
|
|
1681
|
+
return this.call("createBucket", key, {
|
|
1682
|
+
body: withoutUndefined(request),
|
|
1683
|
+
idempotencyKey
|
|
1684
|
+
}).catch(unconfirmed("Bucket creation", idempotencyKey, "the same name and region"));
|
|
1685
|
+
}
|
|
1686
|
+
getBucket(key, name) {
|
|
1687
|
+
return this.call("getBucket", key, { params: { name } });
|
|
1688
|
+
}
|
|
1689
|
+
updateBucket(key, name, change) {
|
|
1690
|
+
return this.call("updateBucket", key, { params: { name }, body: change });
|
|
1691
|
+
}
|
|
1692
|
+
deleteBucket(key, name) {
|
|
1693
|
+
return this.call("deleteBucket", key, { params: { name } });
|
|
1694
|
+
}
|
|
1695
|
+
listDatabases(key, page, prefix) {
|
|
1696
|
+
return this.page("listDatabases", key, page, { prefix });
|
|
1697
|
+
}
|
|
1698
|
+
/** A replay with the same idempotency key returns the database the first
|
|
1699
|
+
* request created. */
|
|
1700
|
+
createDatabase(key, request, idempotencyKey) {
|
|
1701
|
+
return this.call("createDatabase", key, {
|
|
1702
|
+
body: withoutUndefined(request),
|
|
1703
|
+
idempotencyKey
|
|
1704
|
+
}).catch(unconfirmed("Database creation", idempotencyKey, request.expires_at ? `the same name, region and --expires-at ${request.expires_at}` : "the same name and region"));
|
|
1705
|
+
}
|
|
1706
|
+
getDatabase(key, name) {
|
|
1707
|
+
return this.call("getDatabase", key, { params: { name } });
|
|
1708
|
+
}
|
|
1709
|
+
updateDatabase(key, name, change) {
|
|
1710
|
+
return this.call("updateDatabase", key, { params: { name }, body: change });
|
|
1711
|
+
}
|
|
1712
|
+
deleteDatabase(key, name) {
|
|
1713
|
+
return this.call("deleteDatabase", key, { params: { name } });
|
|
1714
|
+
}
|
|
1715
|
+
/** A database's writes per hour or day and its size at each step's end. */
|
|
1716
|
+
getDatabaseActivity(key, name, query = {}) {
|
|
1717
|
+
return this.call("getDatabaseActivity", key, { params: { name }, query });
|
|
1718
|
+
}
|
|
1719
|
+
/** The whole usage page: its rows, `other`, prices and checkpoint. */
|
|
1720
|
+
listDatabaseUsage(key, page, query = {}) {
|
|
1721
|
+
return this.page("listDatabaseUsage", key, page, query);
|
|
1722
|
+
}
|
|
1723
|
+
listStreams(key, page, prefix) {
|
|
1724
|
+
return this.page("listStreams", key, page, { prefix });
|
|
1725
|
+
}
|
|
1726
|
+
/** A replay with the same idempotency key returns the stream the first
|
|
1727
|
+
* request created. */
|
|
1728
|
+
createStream(key, request, idempotencyKey) {
|
|
1729
|
+
return this.call("createStream", key, {
|
|
1730
|
+
body: withoutUndefined(request),
|
|
1731
|
+
idempotencyKey
|
|
1732
|
+
}).catch(unconfirmed("Stream creation", idempotencyKey, request.expires_at ? `the same name, region, content type and --expires-at ${request.expires_at}` : "the same name, region and content type"));
|
|
1733
|
+
}
|
|
1734
|
+
getStream(key, name) {
|
|
1735
|
+
return this.call("getStream", key, { params: { name } });
|
|
1736
|
+
}
|
|
1737
|
+
updateStream(key, name, change) {
|
|
1738
|
+
return this.call("updateStream", key, { params: { name }, body: change });
|
|
1739
|
+
}
|
|
1740
|
+
deleteStream(key, name) {
|
|
1741
|
+
return this.call("deleteStream", key, { params: { name } });
|
|
1742
|
+
}
|
|
1743
|
+
/** A stream's appends per hour or day and its size at each step's end. */
|
|
1744
|
+
getStreamActivity(key, name, query = {}) {
|
|
1745
|
+
return this.call("getStreamActivity", key, { params: { name }, query });
|
|
1746
|
+
}
|
|
1747
|
+
/** The whole usage page: its rows, `other`, prices and checkpoint. */
|
|
1748
|
+
listStreamUsage(key, page, query = {}) {
|
|
1749
|
+
return this.page("listStreamUsage", key, page, query);
|
|
1750
|
+
}
|
|
1751
|
+
getUsage(key, period) {
|
|
1752
|
+
return this.call("getUsage", key, { query: { period } });
|
|
1753
|
+
}
|
|
1754
|
+
getUsageSeries(key, query = {}) {
|
|
1755
|
+
return this.call("getUsageSeries", key, { query });
|
|
1756
|
+
}
|
|
1757
|
+
/** The account's writes of yesterday and today, hourly, per lane. */
|
|
1758
|
+
getUsageToday(key) {
|
|
1759
|
+
return this.call("getUsageToday", key);
|
|
1760
|
+
}
|
|
1761
|
+
listMachines(key, page) {
|
|
1762
|
+
return this.page("listMachines", key, page);
|
|
1763
|
+
}
|
|
1764
|
+
/**
|
|
1765
|
+
* A replay with the same idempotency key returns the Machine the first
|
|
1766
|
+
* request created, so a transport failure or a 5xx answer is retried here
|
|
1767
|
+
* with the same key, at most `CREATE_ATTEMPTS` times, after the answer's
|
|
1768
|
+
* Retry-After (bounded) or a short backoff. A refusal is final; a create
|
|
1769
|
+
* still unconfirmed after the last attempt names the key to retry with.
|
|
1770
|
+
* The answer carries the Machine's `Location` and, while it is creating,
|
|
1771
|
+
* the `Retry-After` to read it again after (machine-v2.json
|
|
1772
|
+
* `creation.create_status`).
|
|
1773
|
+
*/
|
|
1774
|
+
async createMachine(key, request, idempotencyKey) {
|
|
1775
|
+
let last;
|
|
1776
|
+
for (let attempt = 1; attempt <= CREATE_ATTEMPTS; attempt++) {
|
|
1777
|
+
const headers = {
|
|
1778
|
+
location: null,
|
|
1779
|
+
retryAfter: null
|
|
1780
|
+
};
|
|
1781
|
+
try {
|
|
1782
|
+
const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
|
|
1783
|
+
headers.location = response.headers.get("location");
|
|
1784
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1785
|
+
});
|
|
1786
|
+
return {
|
|
1787
|
+
machine: answer.data,
|
|
1788
|
+
location: headers.location,
|
|
1789
|
+
retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
|
|
1790
|
+
};
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
if (error instanceof HttpError && error.status < 500)
|
|
1793
|
+
throw error;
|
|
1794
|
+
last = error;
|
|
1795
|
+
if (attempt === CREATE_ATTEMPTS)
|
|
1796
|
+
break;
|
|
1797
|
+
const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
|
|
1798
|
+
await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
return unconfirmed("Machine creation", idempotencyKey, "the same arguments")(last);
|
|
1802
|
+
}
|
|
1803
|
+
/** The name of the Machine a create's `Location` names, when it is this
|
|
1804
|
+
* client's origin and `getMachine`'s path; null otherwise, so the key is
|
|
1805
|
+
* never sent to another origin and the caller reads the Machine by the
|
|
1806
|
+
* name it created. */
|
|
1807
|
+
machineNameAt(location) {
|
|
1808
|
+
if (!location)
|
|
1809
|
+
return null;
|
|
1810
|
+
let url;
|
|
1811
|
+
try {
|
|
1812
|
+
url = new URL(location, this.apiOrigin);
|
|
1813
|
+
} catch {
|
|
1814
|
+
return null;
|
|
1815
|
+
}
|
|
1816
|
+
if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
|
|
1817
|
+
return null;
|
|
1818
|
+
const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
|
|
1819
|
+
if (!url.pathname.startsWith(prefix))
|
|
1820
|
+
return null;
|
|
1821
|
+
const segment = url.pathname.slice(prefix.length);
|
|
1822
|
+
if (!segment || segment.includes("/"))
|
|
1823
|
+
return null;
|
|
1824
|
+
try {
|
|
1825
|
+
return decodeURIComponent(segment);
|
|
1826
|
+
} catch {
|
|
1827
|
+
return null;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
getMachine(key, name) {
|
|
1831
|
+
return this.call("getMachine", key, { params: { name } });
|
|
1832
|
+
}
|
|
1833
|
+
/** The Machine with its ETag, which is opaque (machine-v2.json `etag`)
|
|
1834
|
+
* and is sent back as it was read. */
|
|
1835
|
+
async getMachineWithEtag(key, name) {
|
|
1836
|
+
const headers = { etag: null };
|
|
1837
|
+
const answer = await this.request("getMachine", key, { params: { name } }, (response) => {
|
|
1838
|
+
headers.etag = response.headers.get("etag");
|
|
1839
|
+
});
|
|
1840
|
+
return { machine: answer.data, etag: headers.etag };
|
|
1841
|
+
}
|
|
1842
|
+
/** Written only to the Machine and revision `etag` names, the ETag as read
|
|
1843
|
+
* (machine-v2 requires If-Match on update); a stale one is refused with
|
|
1844
|
+
* `precondition_failed`. */
|
|
1845
|
+
updateMachine(key, name, etag, change) {
|
|
1846
|
+
return this.call("updateMachine", key, {
|
|
1847
|
+
params: { name },
|
|
1848
|
+
body: withoutUndefined(change),
|
|
1849
|
+
ifMatch: etag
|
|
1850
|
+
});
|
|
1851
|
+
}
|
|
1852
|
+
/** With `etag`, only the Machine that ETag names, at that revision, is put
|
|
1853
|
+
* to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
|
|
1854
|
+
* carries the `Retry-After` to read the Machine again after
|
|
1855
|
+
* (machine-v2.json `sleep.poll_after_seconds`); a 200 carries none. */
|
|
1856
|
+
async sleepMachine(key, name, etag) {
|
|
1857
|
+
const headers = { retryAfter: null };
|
|
1858
|
+
const answer = await this.request("sleepMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1859
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1860
|
+
});
|
|
1861
|
+
return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
|
|
1439
1862
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
return true;
|
|
1451
|
-
}
|
|
1452
|
-
function isStoredLogin(value) {
|
|
1453
|
-
if (!isPlainObject(value))
|
|
1454
|
-
return false;
|
|
1455
|
-
const login = value;
|
|
1456
|
-
const schema = credentialsContract.schema;
|
|
1457
|
-
if (login.version !== credentialsContract.current_version)
|
|
1458
|
-
return false;
|
|
1459
|
-
if (!schema.required.every((name) => Object.hasOwn(login, name)))
|
|
1460
|
-
return false;
|
|
1461
|
-
const properties = schema.properties;
|
|
1462
|
-
return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
|
|
1463
|
-
}
|
|
1464
|
-
function accountLogins(login) {
|
|
1465
|
-
const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
|
|
1466
|
-
...entry,
|
|
1467
|
-
account_id,
|
|
1468
|
-
kind: entry.kind ?? null,
|
|
1469
|
-
name: entry.name ?? null
|
|
1470
|
-
}));
|
|
1471
|
-
if (!entries.some((entry) => entry.account_id === login.account_id))
|
|
1472
|
-
entries.unshift({
|
|
1473
|
-
account_id: login.account_id,
|
|
1474
|
-
key_id: login.key_id,
|
|
1475
|
-
key: login.key,
|
|
1476
|
-
expires_at: login.expires_at,
|
|
1477
|
-
kind: null,
|
|
1478
|
-
name: null
|
|
1863
|
+
/** The Machine being deleted or the device forgotten, with its kind; the
|
|
1864
|
+
* Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
|
|
1865
|
+
* says whether a name was released (`managed`: its create never reached its
|
|
1866
|
+
* region) or a device was forgotten (`byoc`: its region held no row). With
|
|
1867
|
+
* `etag`, only the Machine that ETag names is deleted
|
|
1868
|
+
* (machine-v2.json `etag.if_match.delete` is optional). */
|
|
1869
|
+
async deleteMachine(key, name, etag) {
|
|
1870
|
+
const headers = { kind: null };
|
|
1871
|
+
const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1872
|
+
headers.kind = response.headers.get("x-tonbo-machine-kind");
|
|
1479
1873
|
});
|
|
1480
|
-
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const stat = await lstat(filename);
|
|
1512
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
1513
|
-
throw new Error("Tonbo credential path must be a regular file.");
|
|
1514
|
-
} catch (error) {
|
|
1515
|
-
if (error.code !== "ENOENT")
|
|
1516
|
-
throw error;
|
|
1874
|
+
if (answer?.data)
|
|
1875
|
+
return { machine: answer.data, kind: answer.data.kind };
|
|
1876
|
+
return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
|
|
1877
|
+
}
|
|
1878
|
+
/** One page of a managed Machine's activations, newest first; the cursor
|
|
1879
|
+
* pages that Machine only. */
|
|
1880
|
+
listMachineActivations(key, name, { limit, cursor } = {}) {
|
|
1881
|
+
return this.request("listMachineActivations", key, {
|
|
1882
|
+
params: { name },
|
|
1883
|
+
query: { limit, cursor }
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
/** The whole answer: the shapes and the default one. */
|
|
1887
|
+
listMachineShapes(key) {
|
|
1888
|
+
return this.request("listMachineShapes", key, {});
|
|
1889
|
+
}
|
|
1890
|
+
/** Every Volume of the account, and the regions that could not be read. */
|
|
1891
|
+
listVolumes(key) {
|
|
1892
|
+
return this.request("listVolumes", key, {});
|
|
1893
|
+
}
|
|
1894
|
+
/** A replay with the same idempotency key answers the Volume the first
|
|
1895
|
+
* request created, so an unconfirmed create names the key to retry with. */
|
|
1896
|
+
createVolume(key, request, idempotencyKey) {
|
|
1897
|
+
return this.call("createVolume", key, { body: request, idempotencyKey }).catch(unconfirmed("Volume creation", idempotencyKey, "the same name, region and size"));
|
|
1898
|
+
}
|
|
1899
|
+
getVolume(key, name) {
|
|
1900
|
+
return this.call("getVolume", key, { params: { name } });
|
|
1901
|
+
}
|
|
1902
|
+
/** The Volume being deleted; refused (409) while it is bound. */
|
|
1903
|
+
deleteVolume(key, name) {
|
|
1904
|
+
return this.call("deleteVolume", key, { params: { name } });
|
|
1517
1905
|
}
|
|
1906
|
+
};
|
|
1907
|
+
var CREATE_ATTEMPTS = 3;
|
|
1908
|
+
var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
|
|
1909
|
+
function retryAfterSeconds(value) {
|
|
1910
|
+
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
1911
|
+
return null;
|
|
1912
|
+
return Number(value.trim());
|
|
1518
1913
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1914
|
+
function withoutUndefined(value) {
|
|
1915
|
+
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
1916
|
+
}
|
|
1917
|
+
function unconfirmed(what, idempotencyKey, same) {
|
|
1918
|
+
return (cause) => {
|
|
1919
|
+
if (cause instanceof HttpError && cause.status < 500)
|
|
1920
|
+
throw cause;
|
|
1921
|
+
throw new Error(`${what} was not confirmed: ${cause instanceof Error ? cause.message : String(cause)} Retry with --idempotency-key ${idempotencyKey} and ${same}.`, { cause });
|
|
1922
|
+
};
|
|
1525
1923
|
}
|
|
1526
1924
|
|
|
1527
1925
|
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1926
|
+
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
1927
|
+
import { createServer } from "node:http";
|
|
1928
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1929
|
+
import os4 from "node:os";
|
|
1930
|
+
import { promisify as promisify2 } from "node:util";
|
|
1931
|
+
|
|
1932
|
+
// ../../packages/tonbo-cli-core/dist/src/callback-page.js
|
|
1933
|
+
var WORDMARK = [
|
|
1934
|
+
"\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",
|
|
1935
|
+
"\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
|
|
1936
|
+
"\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"
|
|
1937
|
+
].join("\n");
|
|
1938
|
+
var OUTCOMES = {
|
|
1939
|
+
complete: {
|
|
1940
|
+
status: 200,
|
|
1941
|
+
title: "You are signed in.",
|
|
1942
|
+
body: "Return to your terminal. This tab can be closed."
|
|
1943
|
+
},
|
|
1944
|
+
denied: {
|
|
1945
|
+
status: 400,
|
|
1946
|
+
title: "Login was not completed.",
|
|
1947
|
+
body: "Nothing was authorized. Return to your terminal to try again."
|
|
1948
|
+
},
|
|
1949
|
+
invalid: {
|
|
1950
|
+
// The state did not match, the code was missing, or the path was wrong.
|
|
1951
|
+
// Say that it could not be verified rather than which check failed: the
|
|
1952
|
+
// person who can read this page is not necessarily the one who started
|
|
1953
|
+
// the login.
|
|
1954
|
+
status: 400,
|
|
1955
|
+
title: "This callback could not be verified.",
|
|
1956
|
+
body: "Return to your terminal and start the login again."
|
|
1957
|
+
}
|
|
1532
1958
|
};
|
|
1533
|
-
function
|
|
1534
|
-
const
|
|
1535
|
-
return
|
|
1959
|
+
function renderCallbackPage(outcome) {
|
|
1960
|
+
const { title, body } = OUTCOMES[outcome];
|
|
1961
|
+
return `<!doctype html>
|
|
1962
|
+
<html lang="en">
|
|
1963
|
+
<head>
|
|
1964
|
+
<meta charset="utf-8">
|
|
1965
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1966
|
+
<meta name="robots" content="noindex">
|
|
1967
|
+
<title>Tonbo CLI</title>
|
|
1968
|
+
<style>
|
|
1969
|
+
:root {
|
|
1970
|
+
--paper: #f3f2ee;
|
|
1971
|
+
--ink: #1d1b17;
|
|
1972
|
+
--rule: #c9c5ba;
|
|
1973
|
+
--quiet: #5c564a;
|
|
1974
|
+
--signal: #d64a03;
|
|
1975
|
+
}
|
|
1976
|
+
@media (prefers-color-scheme: dark) {
|
|
1977
|
+
:root {
|
|
1978
|
+
--paper: #1d1b17;
|
|
1979
|
+
--ink: #f3f2ee;
|
|
1980
|
+
--rule: #3a362e;
|
|
1981
|
+
--quiet: #a49e8f;
|
|
1982
|
+
--signal: #f08b4b;
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
* { box-sizing: border-box; }
|
|
1986
|
+
body {
|
|
1987
|
+
margin: 0;
|
|
1988
|
+
min-height: 100vh;
|
|
1989
|
+
display: grid;
|
|
1990
|
+
grid-template-rows: auto 1fr;
|
|
1991
|
+
padding: 0 clamp(20px, 5vw, 72px);
|
|
1992
|
+
background: var(--paper);
|
|
1993
|
+
color: var(--ink);
|
|
1994
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
1995
|
+
-webkit-font-smoothing: antialiased;
|
|
1996
|
+
}
|
|
1997
|
+
header { padding: 28px 0; }
|
|
1998
|
+
/* Same metrics as .auth-wordmark in the app: the glyphs are the ground
|
|
1999
|
+
and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
|
|
2000
|
+
what makes the rows meet without seams. Iosevka is not available here,
|
|
2001
|
+
so it falls back to the platform monospace. */
|
|
2002
|
+
pre.wordmark {
|
|
2003
|
+
margin: 0;
|
|
2004
|
+
font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
2005
|
+
font-size: 8px;
|
|
2006
|
+
font-weight: 400;
|
|
2007
|
+
line-height: 10px;
|
|
2008
|
+
letter-spacing: 0;
|
|
2009
|
+
white-space: pre;
|
|
2010
|
+
color: var(--signal);
|
|
2011
|
+
transform: scaleY(0.8);
|
|
2012
|
+
transform-origin: left top;
|
|
2013
|
+
}
|
|
2014
|
+
main {
|
|
2015
|
+
display: flex;
|
|
2016
|
+
flex-direction: column;
|
|
2017
|
+
justify-content: center;
|
|
2018
|
+
max-width: 46ch;
|
|
2019
|
+
padding-bottom: 12vh;
|
|
2020
|
+
}
|
|
2021
|
+
h1 {
|
|
2022
|
+
margin: 0;
|
|
2023
|
+
font-size: 26px;
|
|
2024
|
+
font-weight: 300;
|
|
2025
|
+
line-height: 1.16;
|
|
2026
|
+
letter-spacing: -0.018em;
|
|
2027
|
+
}
|
|
2028
|
+
p {
|
|
2029
|
+
margin: 14px 0 0;
|
|
2030
|
+
font-size: 16px;
|
|
2031
|
+
line-height: 1.55;
|
|
2032
|
+
color: var(--quiet);
|
|
2033
|
+
}
|
|
2034
|
+
hr {
|
|
2035
|
+
width: 40px;
|
|
2036
|
+
margin: 28px 0 0;
|
|
2037
|
+
border: 0;
|
|
2038
|
+
border-top: 1px solid var(--rule);
|
|
2039
|
+
}
|
|
2040
|
+
</style>
|
|
2041
|
+
</head>
|
|
2042
|
+
<body>
|
|
2043
|
+
<header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
|
|
2044
|
+
<main>
|
|
2045
|
+
<h1>${title}</h1>
|
|
2046
|
+
<p>${body}</p>
|
|
2047
|
+
<hr>
|
|
2048
|
+
</main>
|
|
2049
|
+
</body>
|
|
2050
|
+
</html>
|
|
2051
|
+
`;
|
|
2052
|
+
}
|
|
2053
|
+
function callbackResponse(outcome) {
|
|
2054
|
+
return {
|
|
2055
|
+
status: OUTCOMES[outcome].status,
|
|
2056
|
+
headers: {
|
|
2057
|
+
"content-type": "text/html; charset=utf-8",
|
|
2058
|
+
"cache-control": "no-store"
|
|
2059
|
+
},
|
|
2060
|
+
body: renderCallbackPage(outcome)
|
|
2061
|
+
};
|
|
1536
2062
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
2063
|
+
|
|
2064
|
+
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
2065
|
+
import { randomUUID } from "node:crypto";
|
|
2066
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2067
|
+
import os from "node:os";
|
|
2068
|
+
import path from "node:path";
|
|
2069
|
+
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
2070
|
+
var FILE_MODE = Number.parseInt(credentialsContract.permissions.file_mode, 8);
|
|
2071
|
+
var REFUSED_MODE_BITS = Number.parseInt(credentialsContract.reader.refuse_mode_bits, 8);
|
|
2072
|
+
function defaultCredentialsPath(env = process.env) {
|
|
2073
|
+
const rule = credentialsContract.path;
|
|
2074
|
+
const base = env[rule.base_env] || path.join(...rule.fallback_base.map((part) => part === "$HOME" ? os.homedir() : part));
|
|
2075
|
+
return path.join(base, ...rule.relative);
|
|
1539
2076
|
}
|
|
1540
|
-
function
|
|
1541
|
-
|
|
1542
|
-
return cleaned || "computer";
|
|
2077
|
+
function isExpiredLogin(login, now = Date.now()) {
|
|
2078
|
+
return login.expires_at !== null && Date.parse(login.expires_at) <= now;
|
|
1543
2079
|
}
|
|
1544
|
-
var
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
browserOpener;
|
|
1549
|
-
hostname;
|
|
2080
|
+
var UnusableLoginError = class extends Error {
|
|
2081
|
+
};
|
|
2082
|
+
var FileCredentialStore = class {
|
|
2083
|
+
filename;
|
|
1550
2084
|
loginHint;
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT, entryLine = "cli", env = process.env) {
|
|
1554
|
-
this.credentials = credentials;
|
|
1555
|
-
this.fetcher = fetcher;
|
|
1556
|
-
this.loginOrigin = loginOrigin2;
|
|
1557
|
-
this.browserOpener = browserOpener;
|
|
1558
|
-
this.hostname = hostname;
|
|
2085
|
+
constructor(filename = defaultCredentialsPath(), loginHint = credentialsContract.reader.login_hint) {
|
|
2086
|
+
this.filename = filename;
|
|
1559
2087
|
this.loginHint = loginHint;
|
|
1560
|
-
this.entryLine = entryLine;
|
|
1561
|
-
this.env = env;
|
|
1562
2088
|
}
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
if (
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
if (!login)
|
|
1571
|
-
throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
|
|
1572
|
-
if (isExpiredLogin(login))
|
|
1573
|
-
throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
|
|
1574
|
-
return login.key;
|
|
2089
|
+
async load() {
|
|
2090
|
+
const parsed = await readPrivateJson(this.filename);
|
|
2091
|
+
if (parsed === null)
|
|
2092
|
+
return null;
|
|
2093
|
+
if (!isStoredLogin(parsed))
|
|
2094
|
+
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
2095
|
+
return parsed;
|
|
1575
2096
|
}
|
|
1576
|
-
/**
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
* <8 hex>` in any other). With `device`, in an SSH session without a
|
|
1582
|
-
* display, or when no browser can be opened, the same PKCE challenge goes
|
|
1583
|
-
* to a device-code login instead: the person enters a short code at
|
|
1584
|
-
* /cli/device in any browser and the CLI polls for the key. The key joins
|
|
1585
|
-
* the file's `accounts`; it becomes the top level too unless `current` is
|
|
1586
|
-
* false (a login made only to publish into an organization). Answers that
|
|
1587
|
-
* account's login.
|
|
1588
|
-
*/
|
|
1589
|
-
async login(progress = () => {
|
|
1590
|
-
}, account, { current = true, device } = {}) {
|
|
1591
|
-
const existing = await this.credentials.load().catch((error) => {
|
|
1592
|
-
if (error instanceof UnusableLoginError)
|
|
1593
|
-
return null;
|
|
1594
|
-
throw error;
|
|
1595
|
-
});
|
|
1596
|
-
const loginSecret = existing?.login_secret ?? newLoginSecret();
|
|
1597
|
-
const verifier = randomBytes(48).toString("base64url");
|
|
1598
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
1599
|
-
const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, loginSecret, account, progress) : await this.browserLogin(challenge, verifier, loginSecret, account, progress).catch((error) => {
|
|
1600
|
-
if (!(error instanceof BrowserUnavailableError))
|
|
1601
|
-
throw error;
|
|
1602
|
-
progress({ status: "failed", step: "browser", reason: error.message });
|
|
1603
|
-
return this.deviceLogin(challenge, verifier, loginSecret, account, progress);
|
|
1604
|
-
});
|
|
1605
|
-
const login = {
|
|
1606
|
-
account_id: redeemed.account_id,
|
|
1607
|
-
key_id: redeemed.api_key.id,
|
|
1608
|
-
key: redeemed.key,
|
|
1609
|
-
expires_at: redeemed.api_key.expires_at,
|
|
1610
|
-
kind: redeemed.account_kind ?? null,
|
|
1611
|
-
name: redeemed.account_name ?? null
|
|
1612
|
-
};
|
|
1613
|
-
progress({ status: "started", step: "credential-store" });
|
|
1614
|
-
const latest = await this.credentials.load().catch(() => existing);
|
|
1615
|
-
await this.credentials.save(withAccountLogin(latest, login, {
|
|
1616
|
-
current,
|
|
1617
|
-
loginSecret: latest?.login_secret ?? loginSecret
|
|
1618
|
-
}));
|
|
1619
|
-
progress({ status: "completed", step: "credential-store" });
|
|
1620
|
-
return login;
|
|
2097
|
+
/** Writes a legacy login; nothing but tests writes one from ADR-0018. */
|
|
2098
|
+
async save(login) {
|
|
2099
|
+
if (!isStoredLogin(login))
|
|
2100
|
+
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
2101
|
+
await writePrivateJson(this.filename, login);
|
|
1621
2102
|
}
|
|
1622
|
-
async
|
|
1623
|
-
|
|
1624
|
-
const code = await this.listenForCode(state, (redirectUri) => {
|
|
1625
|
-
const authorization = new URL("/cli/login", this.loginOrigin);
|
|
1626
|
-
authorization.search = new URLSearchParams({
|
|
1627
|
-
code_challenge: challenge,
|
|
1628
|
-
code_challenge_method: "S256",
|
|
1629
|
-
redirect_uri: redirectUri,
|
|
1630
|
-
state,
|
|
1631
|
-
hostname: this.hostname,
|
|
1632
|
-
from: this.entryLine,
|
|
1633
|
-
...account ? { account } : {}
|
|
1634
|
-
}).toString();
|
|
1635
|
-
return authorization.toString();
|
|
1636
|
-
}, progress);
|
|
1637
|
-
progress({ status: "started", step: "key-redemption" });
|
|
1638
|
-
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
1639
|
-
method: "POST",
|
|
1640
|
-
headers: { "content-type": "application/json" },
|
|
1641
|
-
// The secret goes only here, in the POST body, never in the URL.
|
|
1642
|
-
body: JSON.stringify({ code, code_verifier: verifier, login_secret: loginSecret })
|
|
1643
|
-
});
|
|
1644
|
-
progress({ status: "completed", step: "key-redemption" });
|
|
1645
|
-
return redeemed;
|
|
2103
|
+
async remove() {
|
|
2104
|
+
await removePrivateFile(this.filename);
|
|
1646
2105
|
}
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
const started = await requestJson(this.fetcher, new URL("/api/cli/login/device", this.loginOrigin).toString(), {
|
|
1657
|
-
method: "POST",
|
|
1658
|
-
headers: { "content-type": "application/json" },
|
|
1659
|
-
body: JSON.stringify({
|
|
1660
|
-
code_challenge: challenge,
|
|
1661
|
-
code_challenge_method: "S256",
|
|
1662
|
-
hostname: this.hostname
|
|
1663
|
-
})
|
|
1664
|
-
});
|
|
1665
|
-
progress({ status: "completed", step: "device-code" });
|
|
1666
|
-
const page = new URL("/cli/device", this.loginOrigin);
|
|
1667
|
-
const complete = new URL(page);
|
|
1668
|
-
complete.search = new URLSearchParams({
|
|
1669
|
-
code: started.user_code,
|
|
1670
|
-
from: this.entryLine,
|
|
1671
|
-
...account ? { account } : {}
|
|
1672
|
-
}).toString();
|
|
1673
|
-
progress({
|
|
1674
|
-
status: "device-code",
|
|
1675
|
-
step: "device-authorization",
|
|
1676
|
-
userCode: started.user_code,
|
|
1677
|
-
url: page.toString(),
|
|
1678
|
-
completeUrl: complete.toString(),
|
|
1679
|
-
expiresIn: started.expires_in
|
|
1680
|
-
});
|
|
1681
|
-
progress({ status: "started", step: "device-authorization" });
|
|
1682
|
-
const expired = new Error(`The login code expired. Run \`${this.loginHint}\` again.`);
|
|
1683
|
-
const deadline = Date.now() + started.expires_in * 1e3;
|
|
1684
|
-
let interval = started.interval;
|
|
1685
|
-
for (; ; ) {
|
|
1686
|
-
await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
|
|
1687
|
-
try {
|
|
1688
|
-
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
|
|
1689
|
-
method: "POST",
|
|
1690
|
-
headers: { "content-type": "application/json" },
|
|
1691
|
-
body: JSON.stringify({
|
|
1692
|
-
device_code: started.device_code,
|
|
1693
|
-
code_verifier: verifier,
|
|
1694
|
-
login_secret: loginSecret
|
|
1695
|
-
})
|
|
1696
|
-
});
|
|
1697
|
-
progress({ status: "completed", step: "device-authorization" });
|
|
1698
|
-
return redeemed;
|
|
1699
|
-
} catch (error) {
|
|
1700
|
-
const code = error instanceof HttpError ? error.code : null;
|
|
1701
|
-
if (code === "slow_down")
|
|
1702
|
-
interval += 5;
|
|
1703
|
-
else if (code === "access_denied")
|
|
1704
|
-
throw new Error("The login was not approved (access_denied).");
|
|
1705
|
-
else if (code === "expired_token")
|
|
1706
|
-
throw expired;
|
|
1707
|
-
else if (code !== "authorization_pending" && !transient(error))
|
|
1708
|
-
throw error;
|
|
1709
|
-
if (Date.now() >= deadline)
|
|
1710
|
-
throw expired;
|
|
1711
|
-
}
|
|
1712
|
-
}
|
|
2106
|
+
};
|
|
2107
|
+
async function readPrivateJson(filename) {
|
|
2108
|
+
try {
|
|
2109
|
+
await assertPrivateRegularFile(filename);
|
|
2110
|
+
return JSON.parse(await readFile(filename, "utf8"));
|
|
2111
|
+
} catch (error) {
|
|
2112
|
+
if (error.code === "ENOENT")
|
|
2113
|
+
return null;
|
|
2114
|
+
throw new Error(`Could not read Tonbo credentials at ${filename}.`, { cause: error });
|
|
1713
2115
|
}
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
})
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
const { port } = server.address();
|
|
1786
|
-
progress({ status: "completed", step: "callback-server" });
|
|
1787
|
-
const url = authorizationUrl(`http://${LOOPBACK_HOST}:${port}/callback`);
|
|
1788
|
-
progress({ status: "authorization-url", step: "browser", url });
|
|
1789
|
-
progress({ status: "started", step: "browser" });
|
|
1790
|
-
void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
|
|
1791
|
-
if (settled || authorizationStarted)
|
|
1792
|
-
return;
|
|
1793
|
-
finish({
|
|
1794
|
-
error: new BrowserUnavailableError(error instanceof Error ? error.message : String(error))
|
|
1795
|
-
});
|
|
1796
|
-
});
|
|
1797
|
-
});
|
|
1798
|
-
});
|
|
2116
|
+
}
|
|
2117
|
+
async function writePrivateJson(filename, value) {
|
|
2118
|
+
const directory = path.dirname(filename);
|
|
2119
|
+
await mkdir(directory, { mode: DIRECTORY_MODE, recursive: true });
|
|
2120
|
+
await preparePrivateDirectory(directory);
|
|
2121
|
+
await assertExistingDestinationIsSafe(filename);
|
|
2122
|
+
const temporary = path.join(directory, `.${path.basename(filename)}.${process.pid}.${randomUUID()}.tmp`);
|
|
2123
|
+
let handle = null;
|
|
2124
|
+
try {
|
|
2125
|
+
handle = await open(temporary, "wx", FILE_MODE);
|
|
2126
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}
|
|
2127
|
+
`, "utf8");
|
|
2128
|
+
await handle.sync();
|
|
2129
|
+
await handle.close();
|
|
2130
|
+
handle = null;
|
|
2131
|
+
if (process.platform !== "win32")
|
|
2132
|
+
await chmod(temporary, FILE_MODE);
|
|
2133
|
+
await rename(temporary, filename);
|
|
2134
|
+
} catch (error) {
|
|
2135
|
+
await handle?.close().catch(() => void 0);
|
|
2136
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
2137
|
+
throw new Error(`Could not store Tonbo credentials at ${filename}.`, { cause: error });
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
async function removePrivateFile(filename) {
|
|
2141
|
+
await assertExistingDestinationIsSafe(filename);
|
|
2142
|
+
await rm(filename, { force: true });
|
|
2143
|
+
}
|
|
2144
|
+
var KEYWORDS = /* @__PURE__ */ new Set([
|
|
2145
|
+
"$schema",
|
|
2146
|
+
"$id",
|
|
2147
|
+
"title",
|
|
2148
|
+
"description",
|
|
2149
|
+
"const",
|
|
2150
|
+
"type",
|
|
2151
|
+
"minLength",
|
|
2152
|
+
"pattern",
|
|
2153
|
+
"format",
|
|
2154
|
+
"required",
|
|
2155
|
+
"properties",
|
|
2156
|
+
"additionalProperties"
|
|
2157
|
+
]);
|
|
2158
|
+
function isPlainObject(value) {
|
|
2159
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2160
|
+
}
|
|
2161
|
+
function matchesSchema(value, schema) {
|
|
2162
|
+
const rule = schema;
|
|
2163
|
+
if (Object.keys(rule).some((keyword) => !KEYWORDS.has(keyword)))
|
|
2164
|
+
return false;
|
|
2165
|
+
if ("const" in rule)
|
|
2166
|
+
return value === rule.const;
|
|
2167
|
+
const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
|
|
2168
|
+
if (value === null)
|
|
2169
|
+
return types.includes("null");
|
|
2170
|
+
if (isPlainObject(value)) {
|
|
2171
|
+
if (!types.includes("object"))
|
|
2172
|
+
return false;
|
|
2173
|
+
if (!(rule.required ?? []).every((name) => Object.hasOwn(value, name)))
|
|
2174
|
+
return false;
|
|
2175
|
+
const properties = rule.properties ?? {};
|
|
2176
|
+
for (const [name, inner] of Object.entries(value)) {
|
|
2177
|
+
if (Object.hasOwn(properties, name)) {
|
|
2178
|
+
if (!matchesSchema(inner, properties[name]))
|
|
2179
|
+
return false;
|
|
2180
|
+
} else if (isPlainObject(rule.additionalProperties)) {
|
|
2181
|
+
if (!name || !matchesSchema(inner, rule.additionalProperties))
|
|
2182
|
+
return false;
|
|
2183
|
+
} else if (rule.additionalProperties === false)
|
|
2184
|
+
return false;
|
|
2185
|
+
}
|
|
2186
|
+
return true;
|
|
1799
2187
|
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2188
|
+
if (typeof value !== "string" || !types.includes("string"))
|
|
2189
|
+
return false;
|
|
2190
|
+
if (rule.minLength !== void 0 && value.length < rule.minLength)
|
|
2191
|
+
return false;
|
|
2192
|
+
if (rule.pattern !== void 0 && !new RegExp(rule.pattern, "u").test(value))
|
|
2193
|
+
return false;
|
|
2194
|
+
if (rule.format === "date-time" && Number.isNaN(Date.parse(value)))
|
|
2195
|
+
return false;
|
|
2196
|
+
return true;
|
|
1803
2197
|
}
|
|
1804
|
-
function
|
|
1805
|
-
|
|
1806
|
-
if (finished)
|
|
1807
|
-
response.once("finish", finished);
|
|
1808
|
-
response.writeHead(page.status, { ...page.headers, connection: "close" });
|
|
1809
|
-
response.end(page.body);
|
|
2198
|
+
function isStoredLogin(value) {
|
|
2199
|
+
return isPlainObject(value) && value.version === credentialsContract.current_version && matchesSchema(value, credentialsContract.schema);
|
|
1810
2200
|
}
|
|
1811
|
-
function
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2201
|
+
function accountLogins(login) {
|
|
2202
|
+
const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
|
|
2203
|
+
...entry,
|
|
2204
|
+
account_id,
|
|
2205
|
+
kind: entry.kind ?? null,
|
|
2206
|
+
name: entry.name ?? null
|
|
2207
|
+
}));
|
|
2208
|
+
if (!entries.some((entry) => entry.account_id === login.account_id))
|
|
2209
|
+
entries.unshift({
|
|
2210
|
+
account_id: login.account_id,
|
|
2211
|
+
key_id: login.key_id,
|
|
2212
|
+
key: login.key,
|
|
2213
|
+
expires_at: login.expires_at,
|
|
2214
|
+
kind: null,
|
|
2215
|
+
name: null
|
|
2216
|
+
});
|
|
2217
|
+
return entries;
|
|
1819
2218
|
}
|
|
1820
|
-
async function
|
|
1821
|
-
const
|
|
2219
|
+
async function preparePrivateDirectory(directory) {
|
|
2220
|
+
const stat = await lstat(directory);
|
|
2221
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
2222
|
+
throw new Error("Tonbo config directory must be a real directory.");
|
|
2223
|
+
if (process.platform !== "win32")
|
|
2224
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
2225
|
+
}
|
|
2226
|
+
async function assertExistingDestinationIsSafe(filename) {
|
|
1822
2227
|
try {
|
|
1823
|
-
await
|
|
2228
|
+
const stat = await lstat(filename);
|
|
2229
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
2230
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
1824
2231
|
} catch (error) {
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
throw new Error(`${command} is not installed`, { cause: error });
|
|
1828
|
-
if (typeof code === "number")
|
|
1829
|
-
throw new Error(`${command} exited with code ${code}`, { cause: error });
|
|
1830
|
-
throw error;
|
|
2232
|
+
if (error.code !== "ENOENT")
|
|
2233
|
+
throw error;
|
|
1831
2234
|
}
|
|
1832
2235
|
}
|
|
2236
|
+
async function assertPrivateRegularFile(filename) {
|
|
2237
|
+
const stat = await lstat(filename);
|
|
2238
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
2239
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
2240
|
+
if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
|
|
2241
|
+
throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
|
|
2242
|
+
}
|
|
1833
2243
|
|
|
1834
|
-
// ../../packages/tonbo-cli-core/dist/src/
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2244
|
+
// ../../packages/tonbo-cli-core/dist/src/device.js
|
|
2245
|
+
import { execFile } from "node:child_process";
|
|
2246
|
+
import { createHash, randomUUID as randomUUID2 } from "node:crypto";
|
|
2247
|
+
import { link, mkdir as mkdir2, open as open2, readFile as readFile2, rename as rename2, rm as rm2 } from "node:fs/promises";
|
|
2248
|
+
import os2 from "node:os";
|
|
2249
|
+
import path2 from "node:path";
|
|
2250
|
+
import { promisify } from "node:util";
|
|
2251
|
+
function defaultDevicePath(env = process.env, home = os2.homedir()) {
|
|
2252
|
+
const rule = loginContract.device_file;
|
|
2253
|
+
const base = env[rule.base_env] || path2.join(...rule.fallback_base.map((part) => part === "$HOME" ? home : part));
|
|
2254
|
+
return path2.join(base, ...rule.relative);
|
|
2255
|
+
}
|
|
2256
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2257
|
+
var SHA256 = /^[0-9a-f]{64}$/;
|
|
2258
|
+
var execFileAsync = promisify(execFile);
|
|
2259
|
+
async function readMachineId(platform = process.platform) {
|
|
2260
|
+
try {
|
|
2261
|
+
if (platform === "darwin") {
|
|
2262
|
+
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { timeout: 5e3 });
|
|
2263
|
+
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(stdout)?.[1].trim() || null;
|
|
2264
|
+
}
|
|
2265
|
+
if (platform === "linux")
|
|
2266
|
+
return (await readFile2("/etc/machine-id", "utf8")).trim() || null;
|
|
2267
|
+
} catch {
|
|
1850
2268
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
2269
|
+
return null;
|
|
2270
|
+
}
|
|
2271
|
+
function machineIdHash(machineId) {
|
|
2272
|
+
return createHash("sha256").update(machineId).digest("hex");
|
|
2273
|
+
}
|
|
2274
|
+
var DeviceStore = class {
|
|
2275
|
+
filename;
|
|
2276
|
+
machineId;
|
|
2277
|
+
constructor(filename = defaultDevicePath(), machineId = readMachineId) {
|
|
2278
|
+
this.filename = filename;
|
|
2279
|
+
this.machineId = machineId;
|
|
1855
2280
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
2281
|
+
/**
|
|
2282
|
+
* This computer's device id. The first call makes it, a random UUID saved
|
|
2283
|
+
* with the hash of the machine id; two first runs at once agree on the one
|
|
2284
|
+
* saved first. When the saved hash and the machine's differ, the state
|
|
2285
|
+
* directory came from another computer (a cloned disk), and a new id
|
|
2286
|
+
* replaces it. A machine id that cannot be read keeps the saved id.
|
|
2287
|
+
*/
|
|
2288
|
+
async deviceId() {
|
|
2289
|
+
const machineId = await this.machineId().catch(() => null);
|
|
2290
|
+
const hash = machineId ? machineIdHash(machineId) : null;
|
|
2291
|
+
const saved = await this.load();
|
|
2292
|
+
if (saved && (saved.machine_id_sha256 === hash || hash === null))
|
|
2293
|
+
return saved.device_id;
|
|
2294
|
+
if (saved && saved.machine_id_sha256 === null) {
|
|
2295
|
+
await this.write({ ...saved, machine_id_sha256: hash }, true);
|
|
2296
|
+
return saved.device_id;
|
|
1871
2297
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
if (
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2298
|
+
const record = { version: 1, device_id: randomUUID2(), machine_id_sha256: hash };
|
|
2299
|
+
if (saved) {
|
|
2300
|
+
await this.write(record, true);
|
|
2301
|
+
return record.device_id;
|
|
2302
|
+
}
|
|
2303
|
+
if (await this.write(record, false))
|
|
2304
|
+
return record.device_id;
|
|
2305
|
+
const winner = await this.load();
|
|
2306
|
+
if (!winner)
|
|
2307
|
+
throw new Error(`${this.filename} vanished while it was made.`);
|
|
2308
|
+
return winner.device_id;
|
|
2309
|
+
}
|
|
2310
|
+
/** The saved record, or null before the first run. */
|
|
2311
|
+
async load() {
|
|
2312
|
+
let body;
|
|
1883
2313
|
try {
|
|
1884
|
-
|
|
1885
|
-
const response = await this.fetcher(input, init);
|
|
1886
|
-
seen(response);
|
|
1887
|
-
return response;
|
|
1888
|
-
} : this.fetcher;
|
|
1889
|
-
return await requestJson(fetcher, url, {
|
|
1890
|
-
method: operation.method,
|
|
1891
|
-
headers,
|
|
1892
|
-
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1893
|
-
});
|
|
2314
|
+
body = await readFile2(this.filename, "utf8");
|
|
1894
2315
|
} catch (error) {
|
|
1895
|
-
if (error
|
|
1896
|
-
|
|
2316
|
+
if (error.code === "ENOENT")
|
|
2317
|
+
return null;
|
|
1897
2318
|
throw error;
|
|
1898
2319
|
}
|
|
2320
|
+
let parsed = null;
|
|
2321
|
+
try {
|
|
2322
|
+
parsed = JSON.parse(body);
|
|
2323
|
+
} catch {
|
|
2324
|
+
}
|
|
2325
|
+
if (parsed?.version !== 1 || typeof parsed.device_id !== "string" || !UUID.test(parsed.device_id) || !(parsed.machine_id_sha256 === null || SHA256.test(String(parsed.machine_id_sha256))))
|
|
2326
|
+
throw new Error(`${this.filename} is not a Tonbo device file.`);
|
|
2327
|
+
return parsed;
|
|
1899
2328
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2329
|
+
/** Writes `record` through a synced temporary file: in place of the saved
|
|
2330
|
+
* one when `replace`, otherwise only if there is none (false when there
|
|
2331
|
+
* was). */
|
|
2332
|
+
async write(record, replace) {
|
|
2333
|
+
const directory = path2.dirname(this.filename);
|
|
2334
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
2335
|
+
const temporary = path2.join(directory, `.device.${process.pid}.${randomUUID2()}.tmp`);
|
|
2336
|
+
const handle = await open2(temporary, "wx", 384);
|
|
2337
|
+
try {
|
|
2338
|
+
await handle.writeFile(JSON.stringify(record));
|
|
2339
|
+
await handle.sync();
|
|
2340
|
+
} finally {
|
|
2341
|
+
await handle.close();
|
|
2342
|
+
}
|
|
2343
|
+
try {
|
|
2344
|
+
if (replace) {
|
|
2345
|
+
await rename2(temporary, this.filename);
|
|
2346
|
+
return true;
|
|
2347
|
+
}
|
|
2348
|
+
await link(temporary, this.filename);
|
|
2349
|
+
return true;
|
|
2350
|
+
} catch (error) {
|
|
2351
|
+
if (!replace && error.code === "EEXIST")
|
|
2352
|
+
return false;
|
|
2353
|
+
throw error;
|
|
2354
|
+
} finally {
|
|
2355
|
+
await rm2(temporary, { force: true });
|
|
2356
|
+
}
|
|
1911
2357
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
2358
|
+
};
|
|
2359
|
+
|
|
2360
|
+
// ../../packages/tonbo-cli-core/dist/src/session.js
|
|
2361
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2362
|
+
import os3 from "node:os";
|
|
2363
|
+
import path3 from "node:path";
|
|
2364
|
+
function defaultLoginPath(env = process.env) {
|
|
2365
|
+
const rule = loginContract.path;
|
|
2366
|
+
const base = env[rule.base_env] || path3.join(...rule.fallback_base.map((part) => part === "$HOME" ? os3.homedir() : part));
|
|
2367
|
+
return path3.join(base, ...rule.relative);
|
|
2368
|
+
}
|
|
2369
|
+
function isStoredSession(value) {
|
|
2370
|
+
return !!value && typeof value === "object" && value.version === loginContract.current_version && matchesSchema(value, loginContract.schema);
|
|
2371
|
+
}
|
|
2372
|
+
function deviceIdHash(deviceId) {
|
|
2373
|
+
return createHash2("sha256").update(deviceId, "utf8").digest("hex");
|
|
2374
|
+
}
|
|
2375
|
+
function isExpiredSession(stored, now = Date.now()) {
|
|
2376
|
+
return Date.parse(stored.session.expires_at) <= now;
|
|
2377
|
+
}
|
|
2378
|
+
var FileSessionStore = class {
|
|
2379
|
+
filename;
|
|
2380
|
+
loginHint;
|
|
2381
|
+
constructor(filename = defaultLoginPath(), loginHint = loginContract.reader.login_hint) {
|
|
2382
|
+
this.filename = filename;
|
|
2383
|
+
this.loginHint = loginHint;
|
|
1914
2384
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2385
|
+
async load() {
|
|
2386
|
+
const parsed = await readPrivateJson(this.filename);
|
|
2387
|
+
if (parsed === null)
|
|
2388
|
+
return null;
|
|
2389
|
+
if (!isStoredSession(parsed))
|
|
2390
|
+
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
2391
|
+
return parsed;
|
|
1922
2392
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
2393
|
+
async save(stored) {
|
|
2394
|
+
if (!isStoredSession(stored))
|
|
2395
|
+
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
2396
|
+
await writePrivateJson(this.filename, stored);
|
|
1925
2397
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
createApiKey(key, request) {
|
|
1929
|
-
return this.call("createApiKey", key, { body: withoutUndefined(request) });
|
|
2398
|
+
async remove() {
|
|
2399
|
+
await removePrivateFile(this.filename);
|
|
1930
2400
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
2401
|
+
};
|
|
2402
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
2403
|
+
var clients = /* @__PURE__ */ new WeakMap();
|
|
2404
|
+
var SessionClient = class _SessionClient {
|
|
2405
|
+
stored;
|
|
2406
|
+
origins;
|
|
2407
|
+
deviceId;
|
|
2408
|
+
bearers = /* @__PURE__ */ new Map();
|
|
2409
|
+
tokens = /* @__PURE__ */ new Map();
|
|
2410
|
+
inflight = /* @__PURE__ */ new Map();
|
|
2411
|
+
workspaces = /* @__PURE__ */ new Map();
|
|
2412
|
+
constructor(stored, origins, deviceId) {
|
|
2413
|
+
this.stored = stored;
|
|
2414
|
+
this.origins = origins;
|
|
2415
|
+
this.deviceId = deviceId;
|
|
1933
2416
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2417
|
+
/** The client of `stored`, shared by every read of the same login. */
|
|
2418
|
+
static for(stored, origins, deviceId) {
|
|
2419
|
+
const id = `${origins.loginOrigin} ${origins.apiOrigin} ${stored.session.secret}`;
|
|
2420
|
+
let known = clients.get(origins.fetcher);
|
|
2421
|
+
if (!known)
|
|
2422
|
+
clients.set(origins.fetcher, known = /* @__PURE__ */ new Map());
|
|
2423
|
+
const client = known.get(id);
|
|
2424
|
+
if (client) {
|
|
2425
|
+
client.stored = stored;
|
|
2426
|
+
client.deviceId = deviceId;
|
|
2427
|
+
return client;
|
|
2428
|
+
}
|
|
2429
|
+
const made = new _SessionClient(stored, origins, deviceId);
|
|
2430
|
+
known.set(id, made);
|
|
2431
|
+
return made;
|
|
1938
2432
|
}
|
|
1939
|
-
|
|
1940
|
-
return this.
|
|
2433
|
+
now() {
|
|
2434
|
+
return (this.origins.now ?? Date.now)();
|
|
1941
2435
|
}
|
|
1942
|
-
/**
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2436
|
+
/** The bearer of `accountId`: tokens of that account, exchanged from this session. */
|
|
2437
|
+
bearer(accountId) {
|
|
2438
|
+
let bearer = this.bearers.get(accountId);
|
|
2439
|
+
if (!bearer) {
|
|
2440
|
+
bearer = {
|
|
2441
|
+
kind: "session",
|
|
2442
|
+
accountId,
|
|
2443
|
+
token: (audience) => this.token(audience, accountId),
|
|
2444
|
+
invalidate: (audience) => {
|
|
2445
|
+
this.tokens.delete(`${audience} ${accountId}`);
|
|
2446
|
+
}
|
|
2447
|
+
};
|
|
2448
|
+
this.bearers.set(accountId, bearer);
|
|
2449
|
+
}
|
|
2450
|
+
return bearer;
|
|
1949
2451
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
2452
|
+
/** A token of `accountId` for `audience`, kept until a minute before it expires. */
|
|
2453
|
+
async token(audience, accountId) {
|
|
2454
|
+
const id = `${audience} ${accountId}`;
|
|
2455
|
+
const cached = this.tokens.get(id);
|
|
2456
|
+
if (cached && cached.refreshAt > this.now())
|
|
2457
|
+
return cached.token;
|
|
2458
|
+
let pending = this.inflight.get(id);
|
|
2459
|
+
if (!pending) {
|
|
2460
|
+
pending = this.exchange(audience, accountId).finally(() => this.inflight.delete(id));
|
|
2461
|
+
this.inflight.set(id, pending);
|
|
2462
|
+
}
|
|
2463
|
+
return pending;
|
|
1952
2464
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
2465
|
+
/** The device id every exchange and the session endpoint name. */
|
|
2466
|
+
presentedDevice() {
|
|
2467
|
+
if (this.deviceId === null)
|
|
2468
|
+
throw new AccessTokenError(`This login was made on another computer. Run \`${this.origins.loginHint}\` here.`, 401, null, "cli_session_device_mismatch");
|
|
2469
|
+
return this.deviceId;
|
|
1955
2470
|
}
|
|
1956
|
-
|
|
1957
|
-
return this.
|
|
2471
|
+
accountName(accountId) {
|
|
2472
|
+
return this.stored.accounts?.[accountId]?.name ?? accountId;
|
|
1958
2473
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
2474
|
+
/** POST /api/iam/token with the session (access-token-v1.json `cli_session.exchange`). */
|
|
2475
|
+
async exchange(audience, accountId) {
|
|
2476
|
+
const what = audience === GIT_AUDIENCE ? "git" : audience === MANAGEMENT_AUDIENCE ? "Management" : "Streams";
|
|
2477
|
+
if (isExpiredSession(this.stored, this.now()))
|
|
2478
|
+
throw new AccessTokenError(`Your Tonbo login has expired. Run \`${this.origins.loginHint}\`.`, 401, null, "cli_session_invalid");
|
|
2479
|
+
const workspace = audience === GIT_AUDIENCE && accountId !== this.stored.personal_account_id ? await this.defaultWorkspace(accountId) : void 0;
|
|
2480
|
+
const issuedAt = this.now();
|
|
2481
|
+
const body = await this.post({
|
|
2482
|
+
audience,
|
|
2483
|
+
device: this.presentedDevice(),
|
|
2484
|
+
account: accountId,
|
|
2485
|
+
...workspace ? { workspace } : {}
|
|
2486
|
+
}, what, accountId);
|
|
2487
|
+
this.tokens.set(`${audience} ${accountId}`, {
|
|
2488
|
+
token: body.access_token,
|
|
2489
|
+
refreshAt: issuedAt + Math.max(0, body.expires_in * 1e3 - REFRESH_MARGIN_MS)
|
|
2490
|
+
});
|
|
2491
|
+
return body.access_token;
|
|
1961
2492
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2493
|
+
async post(fields2, what, accountId) {
|
|
2494
|
+
let response;
|
|
2495
|
+
try {
|
|
2496
|
+
response = await this.origins.fetcher(`${this.origins.loginOrigin}${cliSessionContract.exchangePath}`, {
|
|
2497
|
+
method: "POST",
|
|
2498
|
+
redirect: "manual",
|
|
2499
|
+
signal: AbortSignal.timeout(3e4),
|
|
2500
|
+
headers: {
|
|
2501
|
+
authorization: `Bearer ${this.stored.session.secret}`,
|
|
2502
|
+
"content-type": "application/json"
|
|
2503
|
+
},
|
|
2504
|
+
body: JSON.stringify(fields2)
|
|
2505
|
+
});
|
|
2506
|
+
} catch (error) {
|
|
2507
|
+
throw new AccessTokenError(`The ${what} token exchange failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2508
|
+
}
|
|
2509
|
+
if (!response.ok)
|
|
2510
|
+
throw await this.refusal(response, what, accountId);
|
|
2511
|
+
const body = await response.json().catch(() => null);
|
|
2512
|
+
if (typeof body?.access_token !== "string" || !body.access_token || typeof body.expires_in !== "number" || !(body.expires_in > 0))
|
|
2513
|
+
throw new AccessTokenError(`The ${what} token exchange answered no token.`, response.status);
|
|
2514
|
+
return { access_token: body.access_token, expires_in: body.expires_in };
|
|
1969
2515
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
2516
|
+
/** The exchange's refusal (`cli_session.exchange.refusals`) as an AccessTokenError. */
|
|
2517
|
+
async refusal(response, what, accountId) {
|
|
2518
|
+
const problem = await response.json().catch(() => null);
|
|
2519
|
+
const code = typeof problem?.code === "string" ? problem.code : null;
|
|
2520
|
+
const retryAfter = Number(response.headers.get("retry-after") ?? "");
|
|
2521
|
+
const hint = this.origins.loginHint;
|
|
2522
|
+
const message = code === "cli_session_device_mismatch" ? `This login was made on another computer. Run \`${hint}\` here.` : response.status === 401 ? `Your Tonbo login has ended (logged out, revoked, unused for ${cliSessionContract.idleDays} days or expired). Run \`${hint}\`.` : code === "account_not_found" ? `You are not a member of ${this.accountName(accountId)}, or it does not exist.` : `The ${what} token exchange answered ${response.status}.`;
|
|
2523
|
+
return new AccessTokenError(message, response.status, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : null, code);
|
|
1972
2524
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
2525
|
+
/** An organization's default git workspace, read with its Management token. */
|
|
2526
|
+
defaultWorkspace(accountId) {
|
|
2527
|
+
let pending = this.workspaces.get(accountId);
|
|
2528
|
+
if (!pending) {
|
|
2529
|
+
pending = this.readDefaultWorkspace(accountId).catch((error) => {
|
|
2530
|
+
this.workspaces.delete(accountId);
|
|
2531
|
+
throw error;
|
|
2532
|
+
});
|
|
2533
|
+
this.workspaces.set(accountId, pending);
|
|
2534
|
+
}
|
|
2535
|
+
return pending;
|
|
1975
2536
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
2537
|
+
async readDefaultWorkspace(accountId) {
|
|
2538
|
+
const token = await this.token(MANAGEMENT_AUDIENCE, accountId);
|
|
2539
|
+
const operation = managementOperations.listGitWorkspaces;
|
|
2540
|
+
let response;
|
|
2541
|
+
try {
|
|
2542
|
+
response = await this.origins.fetcher(`${this.origins.apiOrigin}${managementApiBasePath}${operation.path}?limit=100`, {
|
|
2543
|
+
redirect: "manual",
|
|
2544
|
+
signal: AbortSignal.timeout(3e4),
|
|
2545
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" }
|
|
2546
|
+
});
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2549
|
+
}
|
|
2550
|
+
const body = await response.json().catch(() => null);
|
|
2551
|
+
if (!response.ok)
|
|
2552
|
+
throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces answered ${response.status}.`, response.status);
|
|
2553
|
+
const found = body?.data?.find((workspace) => workspace.default === true);
|
|
2554
|
+
if (typeof found?.id !== "string")
|
|
2555
|
+
throw new AccessTokenError(`${this.accountName(accountId)} has no default git workspace.`, 404, null, "git_workspace_not_found");
|
|
2556
|
+
return found.id;
|
|
1978
2557
|
}
|
|
1979
|
-
/**
|
|
1980
|
-
|
|
1981
|
-
|
|
2558
|
+
/** GET /api/iam/session: the person's accounts now, the personal account first. */
|
|
2559
|
+
async accounts() {
|
|
2560
|
+
const url = new URL(cliSessionContract.sessionPath, this.origins.loginOrigin);
|
|
2561
|
+
url.searchParams.set("device", this.presentedDevice());
|
|
2562
|
+
let response;
|
|
2563
|
+
try {
|
|
2564
|
+
response = await this.origins.fetcher(url.toString(), {
|
|
2565
|
+
redirect: "manual",
|
|
2566
|
+
signal: AbortSignal.timeout(3e4),
|
|
2567
|
+
headers: {
|
|
2568
|
+
authorization: `Bearer ${this.stored.session.secret}`,
|
|
2569
|
+
accept: "application/json"
|
|
2570
|
+
}
|
|
2571
|
+
});
|
|
2572
|
+
} catch (error) {
|
|
2573
|
+
throw new AccessTokenError(`Reading your accounts failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2574
|
+
}
|
|
2575
|
+
if (!response.ok)
|
|
2576
|
+
throw await this.refusal(response, "session", "");
|
|
2577
|
+
const body = await response.json().catch(() => null);
|
|
2578
|
+
if (!Array.isArray(body?.accounts))
|
|
2579
|
+
throw new AccessTokenError("The session endpoint answered no accounts.", response.status);
|
|
2580
|
+
return body.accounts.filter((account) => typeof account?.id === "string" && typeof account.name === "string" && (account.kind === "user" || account.kind === "organization"));
|
|
1982
2581
|
}
|
|
1983
|
-
/**
|
|
1984
|
-
|
|
1985
|
-
|
|
2582
|
+
/** DELETE /api/iam/session: ends the session, from any computer. */
|
|
2583
|
+
async logout() {
|
|
2584
|
+
const response = await this.origins.fetcher(new URL(cliSessionContract.sessionPath, this.origins.loginOrigin).toString(), {
|
|
2585
|
+
method: "DELETE",
|
|
2586
|
+
redirect: "manual",
|
|
2587
|
+
signal: AbortSignal.timeout(3e4),
|
|
2588
|
+
headers: { authorization: `Bearer ${this.stored.session.secret}` }
|
|
2589
|
+
});
|
|
2590
|
+
await response.body?.cancel();
|
|
2591
|
+
if (!response.ok && response.status !== 401)
|
|
2592
|
+
throw new Error(`The logout answered ${response.status}.`);
|
|
1986
2593
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
2594
|
+
};
|
|
2595
|
+
function withAccounts(stored, accounts) {
|
|
2596
|
+
const cache = {};
|
|
2597
|
+
for (const account of accounts)
|
|
2598
|
+
cache[account.id] = { name: account.name, kind: account.kind, role: account.role };
|
|
2599
|
+
return { ...stored, accounts: cache };
|
|
2600
|
+
}
|
|
2601
|
+
var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2602
|
+
var UnknownAccountError = class extends Error {
|
|
2603
|
+
};
|
|
2604
|
+
var LoginStore = class {
|
|
2605
|
+
options;
|
|
2606
|
+
sessions;
|
|
2607
|
+
legacy;
|
|
2608
|
+
loginHint;
|
|
2609
|
+
fetcher;
|
|
2610
|
+
devices;
|
|
2611
|
+
constructor(options) {
|
|
2612
|
+
this.options = options;
|
|
2613
|
+
this.loginHint = options.loginHint ?? loginContract.reader.login_hint;
|
|
2614
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
2615
|
+
this.sessions = options.sessions ?? new FileSessionStore(void 0, this.loginHint);
|
|
2616
|
+
this.legacy = options.legacy ?? new FileCredentialStore(void 0, this.loginHint);
|
|
2617
|
+
this.devices = options.devices ?? new DeviceStore();
|
|
1989
2618
|
}
|
|
1990
|
-
/**
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
return this.call("createStream", key, {
|
|
1994
|
-
body: withoutUndefined(request),
|
|
1995
|
-
idempotencyKey
|
|
1996
|
-
}).catch(unconfirmed("Stream creation", idempotencyKey, request.expires_at ? `the same name, region, content type and --expires-at ${request.expires_at}` : "the same name, region and content type"));
|
|
2619
|
+
/** The account the command named, if any. */
|
|
2620
|
+
get account() {
|
|
2621
|
+
return this.options.account;
|
|
1997
2622
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2623
|
+
/** The session client of this computer's CLI login; null without one. A login whose
|
|
2624
|
+
* device id hash is not this computer's is unusable here (credentials-v2.json
|
|
2625
|
+
* `reader.device`), except with `anyDevice`, whose client can only log out. */
|
|
2626
|
+
async session({ anyDevice = false } = {}) {
|
|
2627
|
+
const stored = await this.sessions.load();
|
|
2628
|
+
return stored ? this.client(stored, anyDevice) : null;
|
|
2000
2629
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2630
|
+
async client(stored, anyDevice = false) {
|
|
2631
|
+
const deviceId = await this.devices.deviceId();
|
|
2632
|
+
const here = deviceIdHash(deviceId) === stored.device_id_sha256;
|
|
2633
|
+
if (!here && !anyDevice)
|
|
2634
|
+
throw new UnusableLoginError(`The saved login at ${this.sessions.filename} was made on another computer. Run \`${this.loginHint}\` here.`);
|
|
2635
|
+
return SessionClient.for(stored, {
|
|
2636
|
+
fetcher: this.fetcher,
|
|
2637
|
+
loginOrigin: this.options.loginOrigin,
|
|
2638
|
+
apiOrigin: this.options.apiOrigin,
|
|
2639
|
+
loginHint: this.loginHint,
|
|
2640
|
+
now: this.options.now
|
|
2641
|
+
}, here ? deviceId : null);
|
|
2003
2642
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2643
|
+
async load() {
|
|
2644
|
+
const stored = await this.sessions.load();
|
|
2645
|
+
if (stored)
|
|
2646
|
+
return this.view(await this.client(stored));
|
|
2647
|
+
const legacy = await this.legacy.load();
|
|
2648
|
+
return legacy && this.options.account ? legacyNamed(legacy, this.options.account) : legacy;
|
|
2006
2649
|
}
|
|
2007
|
-
/**
|
|
2008
|
-
|
|
2009
|
-
|
|
2650
|
+
/** Removes both files. */
|
|
2651
|
+
async remove() {
|
|
2652
|
+
await this.sessions.remove();
|
|
2653
|
+
await this.legacy.remove();
|
|
2010
2654
|
}
|
|
2011
|
-
/** The
|
|
2012
|
-
|
|
2013
|
-
|
|
2655
|
+
/** The person's accounts now, saved as the login's cache of names. */
|
|
2656
|
+
async refreshAccounts(client) {
|
|
2657
|
+
const accounts = await client.accounts();
|
|
2658
|
+
const latest = await this.sessions.load() ?? client.stored;
|
|
2659
|
+
if (latest.session.id === client.stored.session.id) {
|
|
2660
|
+
const saved = withAccounts(latest, accounts);
|
|
2661
|
+
await this.sessions.save(saved);
|
|
2662
|
+
client.stored = saved;
|
|
2663
|
+
}
|
|
2664
|
+
return accounts;
|
|
2014
2665
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2666
|
+
/** The id of the account `nameOrId` names, looked up again once when the cache misses. */
|
|
2667
|
+
async resolve(client, nameOrId) {
|
|
2668
|
+
if (UUID2.test(nameOrId))
|
|
2669
|
+
return nameOrId;
|
|
2670
|
+
const find = (stored) => Object.entries(stored.accounts ?? {}).find(([, entry]) => entry.name.toLowerCase() === nameOrId.toLowerCase())?.[0];
|
|
2671
|
+
const cached = find(client.stored);
|
|
2672
|
+
if (cached)
|
|
2673
|
+
return cached;
|
|
2674
|
+
const found = (await this.refreshAccounts(client)).find((account) => account.name.toLowerCase() === nameOrId.toLowerCase());
|
|
2675
|
+
if (found)
|
|
2676
|
+
return found.id;
|
|
2677
|
+
throw new UnknownAccountError(`You belong to no account named ${nameOrId}. \`tonbo account list\` lists yours.`);
|
|
2017
2678
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2679
|
+
/** The session as one login per account, the top level the command's account. */
|
|
2680
|
+
async view(client) {
|
|
2681
|
+
const stored = client.stored;
|
|
2682
|
+
const chosen = this.options.account ? await this.resolve(client, this.options.account) : stored.default_account_id ?? stored.personal_account_id;
|
|
2683
|
+
const current = client.stored;
|
|
2684
|
+
const accounts = {};
|
|
2685
|
+
const entry = (id) => ({
|
|
2686
|
+
key_id: `session:${current.session.id}`,
|
|
2687
|
+
key: client.bearer(id),
|
|
2688
|
+
expires_at: current.session.expires_at,
|
|
2689
|
+
kind: current.accounts?.[id]?.kind ?? (id === current.personal_account_id ? "user" : null),
|
|
2690
|
+
name: current.accounts?.[id]?.name ?? null
|
|
2691
|
+
});
|
|
2692
|
+
accounts[current.personal_account_id] = entry(current.personal_account_id);
|
|
2693
|
+
for (const id of Object.keys(current.accounts ?? {}))
|
|
2694
|
+
accounts[id] = entry(id);
|
|
2695
|
+
if (!accounts[chosen])
|
|
2696
|
+
accounts[chosen] = entry(chosen);
|
|
2697
|
+
const top = accounts[chosen];
|
|
2698
|
+
return {
|
|
2699
|
+
version: 1,
|
|
2700
|
+
account_id: chosen,
|
|
2701
|
+
key_id: top.key_id,
|
|
2702
|
+
key: top.key,
|
|
2703
|
+
expires_at: top.expires_at,
|
|
2704
|
+
accounts,
|
|
2705
|
+
session: {
|
|
2706
|
+
id: current.session.id,
|
|
2707
|
+
userId: current.user_id,
|
|
2708
|
+
deviceId: client.presentedDevice(),
|
|
2709
|
+
personalAccountId: current.personal_account_id,
|
|
2710
|
+
defaultAccountId: current.default_account_id ?? null,
|
|
2711
|
+
expiresAt: current.session.expires_at
|
|
2712
|
+
}
|
|
2713
|
+
};
|
|
2020
2714
|
}
|
|
2021
|
-
/**
|
|
2022
|
-
|
|
2023
|
-
|
|
2715
|
+
/**
|
|
2716
|
+
* With TONBO_API_KEY, an account the command named must be the key's own,
|
|
2717
|
+
* checked before anything else is called (credentials-v2.json `account`).
|
|
2718
|
+
*/
|
|
2719
|
+
async checkKeyAccount(key) {
|
|
2720
|
+
const named = this.options.account;
|
|
2721
|
+
if (!named)
|
|
2722
|
+
return;
|
|
2723
|
+
const response = await this.fetcher(`${this.options.apiOrigin}${managementApiBasePath}${managementOperations.getAccount.path}`, {
|
|
2724
|
+
redirect: "manual",
|
|
2725
|
+
signal: AbortSignal.timeout(3e4),
|
|
2726
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" }
|
|
2727
|
+
});
|
|
2728
|
+
const body = await response.json().catch(() => null);
|
|
2729
|
+
if (!response.ok)
|
|
2730
|
+
throw new Error(`TONBO_API_KEY was refused (HTTP ${response.status}).`);
|
|
2731
|
+
const account = body?.data;
|
|
2732
|
+
const matches = UUID2.test(named) ? account?.id === named : typeof account?.name === "string" && account.name.toLowerCase() === named.toLowerCase();
|
|
2733
|
+
if (!matches)
|
|
2734
|
+
throw new UnknownAccountError(`TONBO_API_KEY reaches only its own account${typeof account?.name === "string" ? `, ${account.name}` : ""}, not ${named}. Unset --account and TONBO_ACCOUNT, or use ${named}'s key.`);
|
|
2024
2735
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
2736
|
+
};
|
|
2737
|
+
function legacyNamed(login, named) {
|
|
2738
|
+
const wanted = named.toLowerCase();
|
|
2739
|
+
const entry = Object.entries(login.accounts ?? {}).find(([id, value2]) => id === named || value2.name?.toLowerCase() === wanted);
|
|
2740
|
+
if (!entry && login.account_id !== named)
|
|
2741
|
+
throw new UnknownAccountError(`This computer's login has no key of ${named}. Run \`tonbo login\` once: the new login reaches every account you belong to.`);
|
|
2742
|
+
if (!entry)
|
|
2743
|
+
return login;
|
|
2744
|
+
const [account_id, value] = entry;
|
|
2745
|
+
return {
|
|
2746
|
+
...login,
|
|
2747
|
+
account_id,
|
|
2748
|
+
key_id: value.key_id,
|
|
2749
|
+
key: value.key,
|
|
2750
|
+
expires_at: value.expires_at
|
|
2751
|
+
};
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
2755
|
+
var exec = promisify2(execFile2);
|
|
2756
|
+
var DEFAULT_LOGIN_HINT3 = credentialsContract.reader.login_hint;
|
|
2757
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
2758
|
+
var BrowserUnavailableError = class extends Error {
|
|
2759
|
+
};
|
|
2760
|
+
function prefersDeviceLogin(env = process.env) {
|
|
2761
|
+
const ssh = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
2762
|
+
return ssh && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
2763
|
+
}
|
|
2764
|
+
function loginHostname(hostname = os4.hostname()) {
|
|
2765
|
+
const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
2766
|
+
return cleaned || "computer";
|
|
2767
|
+
}
|
|
2768
|
+
function clientFact(value) {
|
|
2769
|
+
return value && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(value) ? value : void 0;
|
|
2770
|
+
}
|
|
2771
|
+
var AuthClient = class {
|
|
2772
|
+
credentials;
|
|
2773
|
+
fetcher;
|
|
2774
|
+
loginOrigin;
|
|
2775
|
+
browserOpener;
|
|
2776
|
+
hostname;
|
|
2777
|
+
loginHint;
|
|
2778
|
+
entryLine;
|
|
2779
|
+
env;
|
|
2780
|
+
facts;
|
|
2781
|
+
keyChecked = null;
|
|
2782
|
+
constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT3, entryLine = "cli", env = process.env, facts = {}) {
|
|
2783
|
+
this.credentials = credentials;
|
|
2784
|
+
this.fetcher = fetcher;
|
|
2785
|
+
this.loginOrigin = loginOrigin2;
|
|
2786
|
+
this.browserOpener = browserOpener;
|
|
2787
|
+
this.hostname = hostname;
|
|
2788
|
+
this.loginHint = loginHint;
|
|
2789
|
+
this.entryLine = entryLine;
|
|
2790
|
+
this.env = env;
|
|
2791
|
+
this.facts = facts;
|
|
2027
2792
|
}
|
|
2028
2793
|
/**
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
2031
|
-
*
|
|
2032
|
-
*
|
|
2033
|
-
* still unconfirmed after the last attempt names the key to retry with.
|
|
2034
|
-
* The answer carries the Machine's `Location` and, while it is creating,
|
|
2035
|
-
* the `Retry-After` to read it again after (machine-v2.json
|
|
2036
|
-
* `creation.create_status`).
|
|
2794
|
+
* The credential of the account a command acts in: TONBO_API_KEY (whose
|
|
2795
|
+
* own account any account the command named must be), or the CLI login's
|
|
2796
|
+
* bearer of the named account, else of its default, else of the personal
|
|
2797
|
+
* account; with only a legacy login, that file's key.
|
|
2037
2798
|
*/
|
|
2038
|
-
async
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
};
|
|
2045
|
-
try {
|
|
2046
|
-
const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
|
|
2047
|
-
headers.location = response.headers.get("location");
|
|
2048
|
-
headers.retryAfter = response.headers.get("retry-after");
|
|
2049
|
-
});
|
|
2050
|
-
return {
|
|
2051
|
-
machine: answer.data,
|
|
2052
|
-
location: headers.location,
|
|
2053
|
-
retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
|
|
2054
|
-
};
|
|
2055
|
-
} catch (error) {
|
|
2056
|
-
if (error instanceof HttpError && error.status < 500)
|
|
2057
|
-
throw error;
|
|
2058
|
-
last = error;
|
|
2059
|
-
if (attempt === CREATE_ATTEMPTS)
|
|
2060
|
-
break;
|
|
2061
|
-
const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
|
|
2062
|
-
await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
|
|
2799
|
+
async apiKey() {
|
|
2800
|
+
const injected = this.env.TONBO_API_KEY?.trim();
|
|
2801
|
+
if (injected) {
|
|
2802
|
+
if (this.keyChecked !== injected) {
|
|
2803
|
+
await this.credentials.checkKeyAccount(injected);
|
|
2804
|
+
this.keyChecked = injected;
|
|
2063
2805
|
}
|
|
2806
|
+
return injected;
|
|
2064
2807
|
}
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
machineNameAt(location) {
|
|
2072
|
-
if (!location)
|
|
2073
|
-
return null;
|
|
2074
|
-
let url;
|
|
2075
|
-
try {
|
|
2076
|
-
url = new URL(location, this.apiOrigin);
|
|
2077
|
-
} catch {
|
|
2078
|
-
return null;
|
|
2079
|
-
}
|
|
2080
|
-
if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
|
|
2081
|
-
return null;
|
|
2082
|
-
const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
|
|
2083
|
-
if (!url.pathname.startsWith(prefix))
|
|
2084
|
-
return null;
|
|
2085
|
-
const segment = url.pathname.slice(prefix.length);
|
|
2086
|
-
if (!segment || segment.includes("/"))
|
|
2087
|
-
return null;
|
|
2088
|
-
try {
|
|
2089
|
-
return decodeURIComponent(segment);
|
|
2090
|
-
} catch {
|
|
2091
|
-
return null;
|
|
2092
|
-
}
|
|
2093
|
-
}
|
|
2094
|
-
getMachine(key, name) {
|
|
2095
|
-
return this.call("getMachine", key, { params: { name } });
|
|
2808
|
+
const login = await this.credentials.load();
|
|
2809
|
+
if (!login)
|
|
2810
|
+
throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
|
|
2811
|
+
if (isExpiredLogin(login))
|
|
2812
|
+
throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
|
|
2813
|
+
return login.key;
|
|
2096
2814
|
}
|
|
2097
|
-
/**
|
|
2098
|
-
*
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2815
|
+
/**
|
|
2816
|
+
* Logs this computer in (ADR-0018): the browser handoff of /cli/login, PKCE
|
|
2817
|
+
* S256 with a loopback redirect on a free port, asks for a session
|
|
2818
|
+
* (`grant=cli_session`); the approval names this computer and asks no
|
|
2819
|
+
* account. With `device`, in an SSH session without a display, or when no
|
|
2820
|
+
* browser can be opened, the same PKCE challenge goes to a device-code
|
|
2821
|
+
* login instead: the person enters a short code at /cli/device in any
|
|
2822
|
+
* browser and the CLI polls. The redeemed session, tied to this
|
|
2823
|
+
* computer's device id, is saved as `login.json` and replaces this
|
|
2824
|
+
* computer's earlier session; the legacy `credentials.json` is left as it
|
|
2825
|
+
* is. With `account` (a name or id), commands act there by default unless
|
|
2826
|
+
* `current` is false. Answers the login of the account commands act in.
|
|
2827
|
+
*/
|
|
2828
|
+
async login(progress = () => {
|
|
2829
|
+
}, account, { current = true, device } = {}) {
|
|
2830
|
+
const deviceId = await (this.facts.devices ?? new DeviceStore()).deviceId();
|
|
2831
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
2832
|
+
const challenge = createHash3("sha256").update(verifier).digest("base64url");
|
|
2833
|
+
const redeem = {
|
|
2834
|
+
grant: cliSessionContract.grant,
|
|
2835
|
+
device_id: deviceId,
|
|
2836
|
+
client: this.facts.client ?? (this.entryLine === "yield" ? "yield" : "tonbo"),
|
|
2837
|
+
...clientFact(this.facts.clientVersion) ? { client_version: clientFact(this.facts.clientVersion) } : {},
|
|
2838
|
+
...clientFact(process.platform) ? { os: process.platform } : {}
|
|
2839
|
+
};
|
|
2840
|
+
const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, redeem, progress) : await this.browserLogin(challenge, verifier, redeem, progress).catch((error) => {
|
|
2841
|
+
if (!(error instanceof BrowserUnavailableError))
|
|
2842
|
+
throw error;
|
|
2843
|
+
progress({ status: "failed", step: "browser", reason: error.message });
|
|
2844
|
+
return this.deviceLogin(challenge, verifier, redeem, progress);
|
|
2103
2845
|
});
|
|
2104
|
-
|
|
2846
|
+
progress({ status: "started", step: "credential-store" });
|
|
2847
|
+
const previous = await this.credentials.sessions.load().catch(() => null);
|
|
2848
|
+
const wanted = account?.toLowerCase();
|
|
2849
|
+
const named = wanted ? redeemed.accounts.find((entry) => entry.id === account || entry.name.toLowerCase() === wanted) : void 0;
|
|
2850
|
+
const kept = previous?.user_id === redeemed.user_id ? previous.default_account_id ?? null : null;
|
|
2851
|
+
const chosen = named && current ? named.id === redeemed.personal_account_id ? null : named.id : kept;
|
|
2852
|
+
const stored = {
|
|
2853
|
+
version: 2,
|
|
2854
|
+
session: redeemed.session,
|
|
2855
|
+
user_id: redeemed.user_id,
|
|
2856
|
+
// The id itself stays in the device file (credentials-v2.json `device_id_sha256`).
|
|
2857
|
+
device_id_sha256: deviceIdHash(deviceId),
|
|
2858
|
+
personal_account_id: redeemed.personal_account_id,
|
|
2859
|
+
default_account_id: chosen && redeemed.accounts.some((entry) => entry.id === chosen) ? chosen : null,
|
|
2860
|
+
accounts: Object.fromEntries(redeemed.accounts.map((entry) => [
|
|
2861
|
+
entry.id,
|
|
2862
|
+
{ name: entry.name, kind: entry.kind, role: entry.role }
|
|
2863
|
+
]))
|
|
2864
|
+
};
|
|
2865
|
+
await this.credentials.sessions.save(stored);
|
|
2866
|
+
progress({ status: "completed", step: "credential-store" });
|
|
2867
|
+
if (account && !named)
|
|
2868
|
+
throw new UnknownAccountError(`Logged in, but you belong to no account named ${account}. Commands act in ${redeemed.accounts.find((entry) => entry.id === redeemed.personal_account_id)?.name ?? "your personal account"}; \`tonbo account list\` lists your accounts.`);
|
|
2869
|
+
const view = await this.credentials.load();
|
|
2870
|
+
const actsIn = named?.id ?? stored.default_account_id ?? stored.personal_account_id;
|
|
2871
|
+
const login = view && accountLogins(view).find((entry) => entry.account_id === actsIn);
|
|
2872
|
+
if (!login)
|
|
2873
|
+
throw new Error("The saved login could not be read back.");
|
|
2874
|
+
return login;
|
|
2105
2875
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2876
|
+
async browserLogin(challenge, verifier, redeem, progress) {
|
|
2877
|
+
const state = randomBytes(24).toString("base64url");
|
|
2878
|
+
const code = await this.listenForCode(state, (redirectUri) => {
|
|
2879
|
+
const authorization = new URL("/cli/login", this.loginOrigin);
|
|
2880
|
+
authorization.search = new URLSearchParams({
|
|
2881
|
+
code_challenge: challenge,
|
|
2882
|
+
code_challenge_method: "S256",
|
|
2883
|
+
redirect_uri: redirectUri,
|
|
2884
|
+
state,
|
|
2885
|
+
hostname: this.hostname,
|
|
2886
|
+
from: this.entryLine,
|
|
2887
|
+
[cliSessionContract.grantParameter]: cliSessionContract.grant
|
|
2888
|
+
}).toString();
|
|
2889
|
+
return authorization.toString();
|
|
2890
|
+
}, progress);
|
|
2891
|
+
progress({ status: "started", step: "key-redemption" });
|
|
2892
|
+
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
2893
|
+
method: "POST",
|
|
2894
|
+
headers: { "content-type": "application/json" },
|
|
2895
|
+
body: JSON.stringify({ code, code_verifier: verifier, ...redeem })
|
|
2114
2896
|
});
|
|
2897
|
+
progress({ status: "completed", step: "key-redemption" });
|
|
2898
|
+
return redeemed;
|
|
2115
2899
|
}
|
|
2116
|
-
/**
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2900
|
+
/**
|
|
2901
|
+
* The device-code login (RFC 8628's shape): the PKCE challenge and the
|
|
2902
|
+
* hostname buy a device code and a user code; the person approves the user
|
|
2903
|
+
* code at /cli/device, seeing the hostname; the CLI polls with the device
|
|
2904
|
+
* code, its verifier and this computer's facts, all in POST bodies, and
|
|
2905
|
+
* the approved poll answers the session once. A denied or expired code
|
|
2906
|
+
* ends it.
|
|
2907
|
+
*/
|
|
2908
|
+
async deviceLogin(challenge, verifier, redeem, progress) {
|
|
2909
|
+
progress({ status: "started", step: "device-code" });
|
|
2910
|
+
const started = await requestJson(this.fetcher, new URL("/api/cli/login/device", this.loginOrigin).toString(), {
|
|
2911
|
+
method: "POST",
|
|
2912
|
+
headers: { "content-type": "application/json" },
|
|
2913
|
+
body: JSON.stringify({
|
|
2914
|
+
code_challenge: challenge,
|
|
2915
|
+
code_challenge_method: "S256",
|
|
2916
|
+
hostname: this.hostname,
|
|
2917
|
+
[cliSessionContract.grantParameter]: cliSessionContract.grant
|
|
2918
|
+
})
|
|
2124
2919
|
});
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2920
|
+
progress({ status: "completed", step: "device-code" });
|
|
2921
|
+
const page = new URL("/cli/device", this.loginOrigin);
|
|
2922
|
+
const complete = new URL(page);
|
|
2923
|
+
complete.search = new URLSearchParams({
|
|
2924
|
+
code: started.user_code,
|
|
2925
|
+
from: this.entryLine
|
|
2926
|
+
}).toString();
|
|
2927
|
+
progress({
|
|
2928
|
+
status: "device-code",
|
|
2929
|
+
step: "device-authorization",
|
|
2930
|
+
userCode: started.user_code,
|
|
2931
|
+
url: page.toString(),
|
|
2932
|
+
completeUrl: complete.toString(),
|
|
2933
|
+
expiresIn: started.expires_in
|
|
2137
2934
|
});
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2935
|
+
progress({ status: "started", step: "device-authorization" });
|
|
2936
|
+
const expired = new Error(`The login code expired. Run \`${this.loginHint}\` again.`);
|
|
2937
|
+
const deadline = Date.now() + started.expires_in * 1e3;
|
|
2938
|
+
let interval = started.interval;
|
|
2939
|
+
for (; ; ) {
|
|
2940
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
|
|
2941
|
+
try {
|
|
2942
|
+
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
|
|
2943
|
+
method: "POST",
|
|
2944
|
+
headers: { "content-type": "application/json" },
|
|
2945
|
+
body: JSON.stringify({
|
|
2946
|
+
device_code: started.device_code,
|
|
2947
|
+
code_verifier: verifier,
|
|
2948
|
+
...redeem
|
|
2949
|
+
})
|
|
2950
|
+
});
|
|
2951
|
+
progress({ status: "completed", step: "device-authorization" });
|
|
2952
|
+
return redeemed;
|
|
2953
|
+
} catch (error) {
|
|
2954
|
+
const code = error instanceof HttpError ? error.code : null;
|
|
2955
|
+
if (code === "slow_down")
|
|
2956
|
+
interval += 5;
|
|
2957
|
+
else if (code === "access_denied")
|
|
2958
|
+
throw new Error("The login was not approved (access_denied).");
|
|
2959
|
+
else if (code === "expired_token")
|
|
2960
|
+
throw expired;
|
|
2961
|
+
else if (code !== "authorization_pending" && !transient(error))
|
|
2962
|
+
throw error;
|
|
2963
|
+
if (Date.now() >= deadline)
|
|
2964
|
+
throw expired;
|
|
2965
|
+
}
|
|
2966
|
+
}
|
|
2141
2967
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2968
|
+
listenForCode(expectedState, authorizationUrl, progress) {
|
|
2969
|
+
return new Promise((resolve, reject) => {
|
|
2970
|
+
let settled = false;
|
|
2971
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
2972
|
+
let authorizationStarted = false;
|
|
2973
|
+
const startAuthorization = () => {
|
|
2974
|
+
if (authorizationStarted)
|
|
2975
|
+
return;
|
|
2976
|
+
authorizationStarted = true;
|
|
2977
|
+
progress({ status: "completed", step: "browser" });
|
|
2978
|
+
progress({ status: "started", step: "browser-authorization" });
|
|
2979
|
+
};
|
|
2980
|
+
const finish = (result, completedResponseSocket) => {
|
|
2981
|
+
if (settled)
|
|
2982
|
+
return;
|
|
2983
|
+
settled = true;
|
|
2984
|
+
clearTimeout(timeout);
|
|
2985
|
+
const complete = (closeError) => {
|
|
2986
|
+
if (closeError)
|
|
2987
|
+
reject(closeError);
|
|
2988
|
+
else if ("code" in result) {
|
|
2989
|
+
progress({ status: "completed", step: "callback-close" });
|
|
2990
|
+
resolve(result.code);
|
|
2991
|
+
} else
|
|
2992
|
+
reject(result.error);
|
|
2993
|
+
};
|
|
2994
|
+
if ("code" in result)
|
|
2995
|
+
progress({ status: "started", step: "callback-close" });
|
|
2996
|
+
if (!server.listening) {
|
|
2997
|
+
complete();
|
|
2998
|
+
return;
|
|
2999
|
+
}
|
|
3000
|
+
server.close(complete);
|
|
3001
|
+
for (const socket of sockets) {
|
|
3002
|
+
if (socket !== completedResponseSocket)
|
|
3003
|
+
socket.destroy();
|
|
3004
|
+
}
|
|
3005
|
+
server.closeIdleConnections();
|
|
3006
|
+
};
|
|
3007
|
+
const timeout = setTimeout(() => {
|
|
3008
|
+
finish({ error: new Error("Timed out waiting for browser login.") });
|
|
3009
|
+
}, 5 * 60 * 1e3);
|
|
3010
|
+
const server = createServer((request, response) => {
|
|
3011
|
+
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
3012
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
|
|
3013
|
+
respond(response, "invalid");
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
3016
|
+
const denied = url.searchParams.get("error");
|
|
3017
|
+
if (denied) {
|
|
3018
|
+
const responseSocket2 = response.socket;
|
|
3019
|
+
respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
const code = url.searchParams.get("code");
|
|
3023
|
+
if (!code) {
|
|
3024
|
+
respond(response, "invalid");
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
3027
|
+
startAuthorization();
|
|
3028
|
+
progress({ status: "completed", step: "browser-authorization" });
|
|
3029
|
+
const responseSocket = response.socket;
|
|
3030
|
+
respond(response, "complete", () => finish({ code }, responseSocket));
|
|
3031
|
+
});
|
|
3032
|
+
server.on("connection", (socket) => {
|
|
3033
|
+
sockets.add(socket);
|
|
3034
|
+
socket.once("close", () => sockets.delete(socket));
|
|
3035
|
+
});
|
|
3036
|
+
server.once("error", (error) => finish({ error }));
|
|
3037
|
+
progress({ status: "started", step: "callback-server" });
|
|
3038
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
3039
|
+
const { port } = server.address();
|
|
3040
|
+
progress({ status: "completed", step: "callback-server" });
|
|
3041
|
+
const url = authorizationUrl(`http://${LOOPBACK_HOST}:${port}/callback`);
|
|
3042
|
+
progress({ status: "authorization-url", step: "browser", url });
|
|
3043
|
+
progress({ status: "started", step: "browser" });
|
|
3044
|
+
void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
|
|
3045
|
+
if (settled || authorizationStarted)
|
|
3046
|
+
return;
|
|
3047
|
+
finish({
|
|
3048
|
+
error: new BrowserUnavailableError(error instanceof Error ? error.message : String(error))
|
|
3049
|
+
});
|
|
3050
|
+
});
|
|
3051
|
+
});
|
|
2148
3052
|
});
|
|
2149
3053
|
}
|
|
2150
|
-
/** The whole answer: the shapes and the default one. */
|
|
2151
|
-
listMachineShapes(key) {
|
|
2152
|
-
return this.request("listMachineShapes", key, {});
|
|
2153
|
-
}
|
|
2154
|
-
/** Every Volume of the account, and the regions that could not be read. */
|
|
2155
|
-
listVolumes(key) {
|
|
2156
|
-
return this.request("listVolumes", key, {});
|
|
2157
|
-
}
|
|
2158
|
-
/** A replay with the same idempotency key answers the Volume the first
|
|
2159
|
-
* request created, so an unconfirmed create names the key to retry with. */
|
|
2160
|
-
createVolume(key, request, idempotencyKey) {
|
|
2161
|
-
return this.call("createVolume", key, { body: request, idempotencyKey }).catch(unconfirmed("Volume creation", idempotencyKey, "the same name, region and size"));
|
|
2162
|
-
}
|
|
2163
|
-
getVolume(key, name) {
|
|
2164
|
-
return this.call("getVolume", key, { params: { name } });
|
|
2165
|
-
}
|
|
2166
|
-
/** The Volume being deleted; refused (409) while it is bound. */
|
|
2167
|
-
deleteVolume(key, name) {
|
|
2168
|
-
return this.call("deleteVolume", key, { params: { name } });
|
|
2169
|
-
}
|
|
2170
3054
|
};
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
function retryAfterSeconds(value) {
|
|
2174
|
-
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
2175
|
-
return null;
|
|
2176
|
-
return Number(value.trim());
|
|
3055
|
+
function transient(error) {
|
|
3056
|
+
return error instanceof HttpError ? error.status === 429 || error.status >= 500 : error instanceof TypeError;
|
|
2177
3057
|
}
|
|
2178
|
-
function
|
|
2179
|
-
|
|
3058
|
+
function respond(response, outcome, finished) {
|
|
3059
|
+
const page = callbackResponse(outcome);
|
|
3060
|
+
if (finished)
|
|
3061
|
+
response.once("finish", finished);
|
|
3062
|
+
response.writeHead(page.status, { ...page.headers, connection: "close" });
|
|
3063
|
+
response.end(page.body);
|
|
2180
3064
|
}
|
|
2181
|
-
function
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
3065
|
+
function browserCommand(url, platform = process.platform, env = process.env) {
|
|
3066
|
+
if (platform === "darwin")
|
|
3067
|
+
return ["open", [url]];
|
|
3068
|
+
if (platform === "win32")
|
|
3069
|
+
return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
3070
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
3071
|
+
throw new Error("there is no graphical session (DISPLAY and WAYLAND_DISPLAY are unset)");
|
|
3072
|
+
return ["xdg-open", [url]];
|
|
3073
|
+
}
|
|
3074
|
+
async function openBrowser(url) {
|
|
3075
|
+
const [command, args] = browserCommand(url);
|
|
3076
|
+
try {
|
|
3077
|
+
await exec(command, args);
|
|
3078
|
+
} catch (error) {
|
|
3079
|
+
const code = error.code;
|
|
3080
|
+
if (code === "ENOENT")
|
|
3081
|
+
throw new Error(`${command} is not installed`, { cause: error });
|
|
3082
|
+
if (typeof code === "number")
|
|
3083
|
+
throw new Error(`${command} exited with code ${code}`, { cause: error });
|
|
3084
|
+
throw error;
|
|
3085
|
+
}
|
|
2187
3086
|
}
|
|
2188
3087
|
|
|
2189
3088
|
// ../../packages/tonbo-cli-core/dist/src/format.js
|
|
@@ -2228,7 +3127,12 @@ function when(timestamp, absent = "never") {
|
|
|
2228
3127
|
async function loginCommand(deps, options = {}) {
|
|
2229
3128
|
try {
|
|
2230
3129
|
const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current, device: options.device });
|
|
2231
|
-
|
|
3130
|
+
const file = await deps.credentials.load();
|
|
3131
|
+
const names = file ? accountLogins(file).map((entry) => entry.kind === "user" ? `${entry.name ?? "you"} (personal)` : entry.name ?? entry.account_id) : [];
|
|
3132
|
+
deps.output([
|
|
3133
|
+
`Logged in. This computer can act in every account you belong to, with your role in each${names.length ? `: ${names.join(", ")}` : ""}.`,
|
|
3134
|
+
`Commands act in ${login.kind === "user" ? "your personal account" : login.name ?? login.account_id} unless you pass --account.${login.expires_at ? ` The login ends ${login.expires_at.slice(0, 10)}, or after 30 days unused.` : ""}`
|
|
3135
|
+
].join("\n"));
|
|
2232
3136
|
return login;
|
|
2233
3137
|
} catch (error) {
|
|
2234
3138
|
deps.progress.fail();
|
|
@@ -2253,8 +3157,8 @@ var LOGIN_PROGRESS_MESSAGES = {
|
|
|
2253
3157
|
completed: "Local browser callback closed"
|
|
2254
3158
|
},
|
|
2255
3159
|
"key-redemption": {
|
|
2256
|
-
started: "Receiving the CLI
|
|
2257
|
-
completed: "CLI
|
|
3160
|
+
started: "Receiving the CLI login",
|
|
3161
|
+
completed: "CLI login received"
|
|
2258
3162
|
},
|
|
2259
3163
|
"device-code": {
|
|
2260
3164
|
started: "Requesting a login code",
|
|
@@ -2291,37 +3195,46 @@ function reportLoginProgress(progress, event) {
|
|
|
2291
3195
|
progress.succeed(messages.completed);
|
|
2292
3196
|
}
|
|
2293
3197
|
async function logoutCommand(deps) {
|
|
2294
|
-
|
|
3198
|
+
const failures = [];
|
|
3199
|
+
let first;
|
|
3200
|
+
const failed = (what, error) => {
|
|
3201
|
+
const failure = error instanceof Error ? error : new Error("The revocation failed.");
|
|
3202
|
+
first ??= failure;
|
|
3203
|
+
failures.push(`${what}: ${failure.message}`);
|
|
3204
|
+
};
|
|
3205
|
+
let session = null;
|
|
2295
3206
|
try {
|
|
2296
|
-
|
|
3207
|
+
session = await deps.credentials.session({ anyDevice: true });
|
|
2297
3208
|
} catch (error) {
|
|
2298
3209
|
if (!(error instanceof UnusableLoginError))
|
|
2299
3210
|
throw error;
|
|
2300
|
-
await deps.credentials.remove();
|
|
2301
|
-
deps.output("Removed a saved login this CLI cannot use; it had no key to revoke.");
|
|
2302
|
-
return;
|
|
2303
3211
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
3212
|
+
let legacy = null;
|
|
3213
|
+
try {
|
|
3214
|
+
legacy = await deps.credentials.legacy.load();
|
|
3215
|
+
} catch (error) {
|
|
3216
|
+
if (!(error instanceof UnusableLoginError))
|
|
3217
|
+
throw error;
|
|
2307
3218
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
for (const entry of accountLogins(
|
|
3219
|
+
if (session)
|
|
3220
|
+
await session.logout().catch((error) => failed("the CLI login", error));
|
|
3221
|
+
for (const entry of legacy ? accountLogins(legacy) : []) {
|
|
2311
3222
|
try {
|
|
2312
3223
|
await deps.api.revokeApiKey(entry.key, entry.key_id);
|
|
2313
3224
|
} catch (error) {
|
|
2314
3225
|
if (error instanceof HttpError && (error.status === 401 || error.status === 404))
|
|
2315
3226
|
continue;
|
|
2316
|
-
|
|
2317
|
-
first ??= failure;
|
|
2318
|
-
failures.push(`${entry.key_id}: ${failure.message}`);
|
|
3227
|
+
failed(entry.key_id, error);
|
|
2319
3228
|
}
|
|
2320
3229
|
}
|
|
2321
3230
|
await deps.credentials.remove();
|
|
3231
|
+
if (!session && !legacy) {
|
|
3232
|
+
deps.output("Not logged in.");
|
|
3233
|
+
return;
|
|
3234
|
+
}
|
|
2322
3235
|
if (failures.length)
|
|
2323
|
-
throw new Error(`Removed the saved login, but could not revoke ${failures.
|
|
2324
|
-
deps.output(
|
|
3236
|
+
throw new Error(`Removed the saved login, but could not revoke ${failures.join("; ")}. Revoke ${failures.length === 1 ? "it" : "them"} on the Sign-in sessions page or under API keys in the Tonbo console.`, { cause: first });
|
|
3237
|
+
deps.output(session ? "Logged out. This computer's CLI login is ended for every account." : "Logged out. Every account's CLI key on this computer is revoked.");
|
|
2325
3238
|
}
|
|
2326
3239
|
|
|
2327
3240
|
// ../../packages/tonbo-cli-core/dist/src/origin.js
|
|
@@ -2410,7 +3323,7 @@ var TerminalProgress = class {
|
|
|
2410
3323
|
};
|
|
2411
3324
|
|
|
2412
3325
|
// src/commands.ts
|
|
2413
|
-
import { randomUUID as
|
|
3326
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2414
3327
|
|
|
2415
3328
|
// src/agent-name.ts
|
|
2416
3329
|
var AGENT_NAME_RULE = agentNameContract.$defs.name;
|
|
@@ -2467,7 +3380,7 @@ async function agentCreateCommand(deps, name, options = {}) {
|
|
|
2467
3380
|
const agent = await deps.api.createAgent(
|
|
2468
3381
|
key,
|
|
2469
3382
|
{ name, region, ...options.role === void 0 ? {} : { role: options.role } },
|
|
2470
|
-
options.idempotencyKey ??
|
|
3383
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2471
3384
|
);
|
|
2472
3385
|
print(
|
|
2473
3386
|
deps,
|
|
@@ -2756,7 +3669,7 @@ async function bucketCreateCommand(deps, name, options = {}) {
|
|
|
2756
3669
|
const bucket = await deps.api.createBucket(
|
|
2757
3670
|
await deps.auth.apiKey(),
|
|
2758
3671
|
{ name, region: options.region },
|
|
2759
|
-
options.idempotencyKey ??
|
|
3672
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2760
3673
|
);
|
|
2761
3674
|
print(
|
|
2762
3675
|
deps,
|
|
@@ -2887,7 +3800,7 @@ async function databaseCreateCommand(deps, name, options = {}) {
|
|
|
2887
3800
|
const database = await deps.api.createDatabase(
|
|
2888
3801
|
await deps.auth.apiKey(),
|
|
2889
3802
|
{ name, region: options.region, expires_at: expiresAt },
|
|
2890
|
-
options.idempotencyKey ??
|
|
3803
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2891
3804
|
);
|
|
2892
3805
|
print(
|
|
2893
3806
|
deps,
|
|
@@ -3075,7 +3988,7 @@ async function streamCreateCommand(deps, name, options = {}) {
|
|
|
3075
3988
|
content_type: options.contentType,
|
|
3076
3989
|
expires_at: expiresAt
|
|
3077
3990
|
},
|
|
3078
|
-
options.idempotencyKey ??
|
|
3991
|
+
options.idempotencyKey ?? randomUUID3()
|
|
3079
3992
|
);
|
|
3080
3993
|
print(
|
|
3081
3994
|
deps,
|
|
@@ -3238,14 +4151,71 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
3238
4151
|
);
|
|
3239
4152
|
}
|
|
3240
4153
|
|
|
4154
|
+
// src/account.ts
|
|
4155
|
+
async function sessionOf(deps, command) {
|
|
4156
|
+
const session = await deps.credentials.session();
|
|
4157
|
+
if (!session)
|
|
4158
|
+
throw new Error(
|
|
4159
|
+
`\`${command}\` needs the CLI login that reaches every account you belong to. Run \`tonbo login\`.`
|
|
4160
|
+
);
|
|
4161
|
+
return session;
|
|
4162
|
+
}
|
|
4163
|
+
async function accountListCommand(deps, options = {}) {
|
|
4164
|
+
if (process.env.TONBO_API_KEY?.trim())
|
|
4165
|
+
throw new Error(
|
|
4166
|
+
"TONBO_API_KEY reaches only its own account (`tonbo account show`). Unset it to list the accounts your login reaches."
|
|
4167
|
+
);
|
|
4168
|
+
const session = await sessionOf(deps, "tonbo account list");
|
|
4169
|
+
const accounts = await deps.credentials.refreshAccounts(session);
|
|
4170
|
+
const chosen = session.stored.default_account_id ?? session.stored.personal_account_id;
|
|
4171
|
+
const rows = accounts.map((account) => ({
|
|
4172
|
+
...account,
|
|
4173
|
+
default: account.id === chosen
|
|
4174
|
+
}));
|
|
4175
|
+
print(
|
|
4176
|
+
deps,
|
|
4177
|
+
options.json,
|
|
4178
|
+
rows,
|
|
4179
|
+
table(
|
|
4180
|
+
["", "NAME", "KIND", "YOUR ROLE", "ID"],
|
|
4181
|
+
rows.map((row) => [row.default ? "*" : "", row.name, row.kind, row.role, row.id])
|
|
4182
|
+
)
|
|
4183
|
+
);
|
|
4184
|
+
}
|
|
4185
|
+
async function accountUseCommand(deps, account, options = {}) {
|
|
4186
|
+
const session = await sessionOf(deps, "tonbo account use");
|
|
4187
|
+
const wanted = account.toLowerCase();
|
|
4188
|
+
const accounts = await deps.credentials.refreshAccounts(session);
|
|
4189
|
+
const found = accounts.find(
|
|
4190
|
+
(entry) => entry.id === account || entry.name.toLowerCase() === wanted
|
|
4191
|
+
);
|
|
4192
|
+
if (!found)
|
|
4193
|
+
throw new UnknownAccountError(
|
|
4194
|
+
`You belong to no account named ${account}. \`tonbo account list\` lists yours.`
|
|
4195
|
+
);
|
|
4196
|
+
const stored = session.stored;
|
|
4197
|
+
const saved = {
|
|
4198
|
+
...stored,
|
|
4199
|
+
default_account_id: found.id === stored.personal_account_id ? null : found.id
|
|
4200
|
+
};
|
|
4201
|
+
await deps.credentials.sessions.save(saved);
|
|
4202
|
+
session.stored = saved;
|
|
4203
|
+
print(
|
|
4204
|
+
deps,
|
|
4205
|
+
options.json,
|
|
4206
|
+
{ ...found, default: true },
|
|
4207
|
+
`Commands act in ${found.kind === "user" ? `${found.name}, your personal account,` : found.name} from now on, unless you pass --account.`
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
|
|
3241
4211
|
// src/help.ts
|
|
3242
4212
|
import { Help } from "commander";
|
|
3243
4213
|
|
|
3244
4214
|
// src/machine.ts
|
|
3245
|
-
import { randomUUID as
|
|
4215
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3246
4216
|
|
|
3247
4217
|
// src/volume.ts
|
|
3248
|
-
import { randomUUID as
|
|
4218
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
3249
4219
|
var VOLUME_DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees/data#volume";
|
|
3250
4220
|
var VOLUME_ERROR_HINTS = {
|
|
3251
4221
|
payment_required: "Add credit at <billing>, then create the Volume again.",
|
|
@@ -3331,7 +4301,7 @@ async function volumeCreateCommand(deps, name, options = {}) {
|
|
|
3331
4301
|
const volume = await deps.api.createVolume(
|
|
3332
4302
|
key,
|
|
3333
4303
|
{ name, region, size_gib: sizeGib },
|
|
3334
|
-
options.idempotencyKey ??
|
|
4304
|
+
options.idempotencyKey ?? randomUUID4()
|
|
3335
4305
|
);
|
|
3336
4306
|
print(
|
|
3337
4307
|
deps,
|
|
@@ -3389,7 +4359,6 @@ var MACHINE_ERROR_HINTS = {
|
|
|
3389
4359
|
precondition_failed: "The Machine changed since it was read, or another Machine now has its name; `tonbo machine show` it and run the command again.",
|
|
3390
4360
|
machine_failed: "A Machine whose creation failed does not change: `tonbo machine show` says why; delete it with `tonbo machine delete` and create it again.",
|
|
3391
4361
|
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
3392
|
-
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
|
|
3393
4362
|
machine_limit_reached: "The account holds as many Machines as it may: delete one you no longer need (`tonbo machine list`), or ask for more.",
|
|
3394
4363
|
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
3395
4364
|
create_rate_limited: "Too many Machines were created in the last hour; try again later.",
|
|
@@ -3405,8 +4374,8 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
|
3405
4374
|
image_unsupported: "Rebuild the image without the entry the message names"
|
|
3406
4375
|
};
|
|
3407
4376
|
var MACHINE_CREATION_FAILURE_HINTS = {
|
|
3408
|
-
database_creation_failed: "
|
|
3409
|
-
database_limit_reached: "
|
|
4377
|
+
database_creation_failed: "A stream of the Machine (its logs) could not be created; the message names the stream and the code. If the code is payment_required, add credit at <billing> first; then delete the Machine and create it again.",
|
|
4378
|
+
database_limit_reached: "The account's database limit was reached: delete a database (`tonbo database list`), then delete this Machine and create it again.",
|
|
3410
4379
|
payment_required: "The account's credit is exhausted: add credit at <billing>, then delete the Machine and create it again.",
|
|
3411
4380
|
...Object.fromEntries(
|
|
3412
4381
|
Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
|
|
@@ -3507,6 +4476,7 @@ var MACHINE_WAKE_BLOCKED_HINTS = {
|
|
|
3507
4476
|
capacity: "No host had room for its shape; the next request tries again.",
|
|
3508
4477
|
region_closed: "Machines are closed in its region for now; the next request after they reopen wakes it."
|
|
3509
4478
|
};
|
|
4479
|
+
var MACHINE_ACCOUNT_EGRESS_BLOCKED_HINT = "If this is the free egress cap: 10 GiB a month without purchased credit; buy credit at <billing> to lift it.";
|
|
3510
4480
|
function conditionText(name, condition, billing) {
|
|
3511
4481
|
switch (condition.type) {
|
|
3512
4482
|
case "wake_blocked": {
|
|
@@ -3518,8 +4488,10 @@ function conditionText(name, condition, billing) {
|
|
|
3518
4488
|
return `still creating since ${condition.since}, longer than usual; Tonbo's operators are alerted, and creation goes on until it is active or failed.`;
|
|
3519
4489
|
case "node_unreachable":
|
|
3520
4490
|
return `its host is unreachable since ${condition.since}`;
|
|
3521
|
-
case "egress_blocked":
|
|
3522
|
-
|
|
4491
|
+
case "egress_blocked": {
|
|
4492
|
+
const blocked = `outbound access blocked for the ${condition.scope} since ${condition.since}${condition.until ? ` until ${condition.until}` : ""}`;
|
|
4493
|
+
return condition.scope === "account" && condition.until ? `${blocked}. ${MACHINE_ACCOUNT_EGRESS_BLOCKED_HINT.replaceAll("<billing>", billing)}` : blocked;
|
|
4494
|
+
}
|
|
3523
4495
|
case "volume_purged":
|
|
3524
4496
|
return `its Volume ${condition.volume} was purged (${condition.reason.replaceAll("_", " ")}) since ${condition.since}; its data is gone and it boots without it. Bind another with \`tonbo machine update ${name} --volume <volume>:<path>\`, or none with \`--no-volume\`.`;
|
|
3525
4497
|
default:
|
|
@@ -3593,7 +4565,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
|
|
|
3593
4565
|
["Stop grace", `${machine.stop_grace_seconds} s`],
|
|
3594
4566
|
["Volume", volumeText(machine)],
|
|
3595
4567
|
...deleteFields(machine),
|
|
3596
|
-
["Database", `${machine.database.name} (${machine.database.url})`],
|
|
4568
|
+
["Database", machine.database ? `${machine.database.name} (${machine.database.url})` : "none"],
|
|
3597
4569
|
["Logs", machine.logs.url],
|
|
3598
4570
|
["Durability", DURABILITY_LINK],
|
|
3599
4571
|
["Revision", String(machine.revision)],
|
|
@@ -3871,7 +4843,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
|
|
|
3871
4843
|
...bound ? { volumes: [bound.binding] } : {},
|
|
3872
4844
|
delete_after_seconds: deleteAfterSeconds(options.deleteAfter)
|
|
3873
4845
|
},
|
|
3874
|
-
options.idempotencyKey ??
|
|
4846
|
+
options.idempotencyKey ?? randomUUID5()
|
|
3875
4847
|
);
|
|
3876
4848
|
} catch (error) {
|
|
3877
4849
|
if (error instanceof HttpError && error.code === "name_taken")
|
|
@@ -4101,9 +5073,11 @@ async function machineDeleteCommand(deps, name, options = {}) {
|
|
|
4101
5073
|
}
|
|
4102
5074
|
async function deleteQuestion(deps, key, name, read) {
|
|
4103
5075
|
let kind = null;
|
|
5076
|
+
let ownDatabase = false;
|
|
4104
5077
|
try {
|
|
4105
5078
|
const answer = await deps.api.getMachineWithEtag(key, name);
|
|
4106
5079
|
kind = answer.machine.kind;
|
|
5080
|
+
ownDatabase = answer.machine.kind === "managed" && !!answer.machine.database?.name.startsWith("m.");
|
|
4107
5081
|
read.etag = answer.etag;
|
|
4108
5082
|
} catch (error) {
|
|
4109
5083
|
if (!(error instanceof HttpError && error.status === 404)) throw error;
|
|
@@ -4111,7 +5085,7 @@ async function deleteQuestion(deps, key, name, read) {
|
|
|
4111
5085
|
if (kind === "byoc")
|
|
4112
5086
|
return `Forget your computer ${name}? It disappears from your Machines; its key is not revoked. Type its name to confirm:`;
|
|
4113
5087
|
if (kind === "managed")
|
|
4114
|
-
return `Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`;
|
|
5088
|
+
return `Delete Machine ${name} with its disk${ownDatabase ? ", database" : ""} and logs? This cannot be undone. Type the Machine's name to confirm:`;
|
|
4115
5089
|
return `Delete Machine ${name}? Type its name to confirm:`;
|
|
4116
5090
|
}
|
|
4117
5091
|
function deletedText(name, machine, kind) {
|
|
@@ -4151,7 +5125,11 @@ async function machineShapesCommand(deps, options = {}) {
|
|
|
4151
5125
|
var ENVIRONMENT = [
|
|
4152
5126
|
{
|
|
4153
5127
|
names: ["TONBO_API_KEY"],
|
|
4154
|
-
description: "
|
|
5128
|
+
description: "An API key (`tbo_\u2026`) of one account that every command uses instead of the saved login, for non-interactive use such as CI and Agents. `--account` and `TONBO_ACCOUNT` must then name the key's own account. `tonbo logout` acts only on the saved login."
|
|
5129
|
+
},
|
|
5130
|
+
{
|
|
5131
|
+
names: ["TONBO_ACCOUNT"],
|
|
5132
|
+
description: "The account commands act in, by name or id, when `--account` is not given; else the default `tonbo account use` set, else your personal account."
|
|
4155
5133
|
},
|
|
4156
5134
|
{
|
|
4157
5135
|
names: ["TONBO_API_ORIGIN"],
|
|
@@ -4159,7 +5137,11 @@ var ENVIRONMENT = [
|
|
|
4159
5137
|
},
|
|
4160
5138
|
{
|
|
4161
5139
|
names: ["XDG_CONFIG_HOME"],
|
|
4162
|
-
description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/
|
|
5140
|
+
description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/login.json` when it is set and not empty, else `$HOME/.config/tonbo/login.json`. A login an earlier release saved, `credentials.json` beside it, is read only when there is no `login.json`."
|
|
5141
|
+
},
|
|
5142
|
+
{
|
|
5143
|
+
names: ["XDG_STATE_HOME"],
|
|
5144
|
+
description: "Where this computer's device id lives, which the login is tied to: `$XDG_STATE_HOME/tonbo-yield/device.json` when it is set and not empty, else `$HOME/.local/state/tonbo-yield/device.json`. `yield` shares it."
|
|
4163
5145
|
},
|
|
4164
5146
|
{
|
|
4165
5147
|
names: ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"],
|
|
@@ -4195,10 +5177,11 @@ var TOPICS = [
|
|
|
4195
5177
|
},
|
|
4196
5178
|
{
|
|
4197
5179
|
id: "credentials",
|
|
4198
|
-
title: "
|
|
5180
|
+
title: "The login, accounts and keys",
|
|
4199
5181
|
text: [
|
|
4200
|
-
"
|
|
4201
|
-
"
|
|
5182
|
+
"Log in once per computer: `tonbo login` signs this computer in as you, and every account you belong to is reachable with `--account`, with your role in each. Joining an organization needs no new login, and leaving one ends this computer's access to it at once. A command acts in one account: `--account`, else `TONBO_ACCOUNT`, else the default `tonbo account use` set, else your personal account. For each request the CLI exchanges the login for a short-lived token of that account; what a command may do is what your role there allows, and the CLI never decides it.",
|
|
5183
|
+
"`TONBO_API_KEY`, when it is set, is used instead: an API key reaches only its own account and is sent as the Bearer credential, as narrowed by the key. Agents and CI should use an Agent key or an API key of one account, never your login, which reaches every account you belong to.",
|
|
5184
|
+
"The login is saved at `${XDG_CONFIG_HOME:-$HOME/.config}/tonbo/login.json`, which `yield` shares: either CLI's `login` signs both in, and either `logout` signs both out. It works only on the computer that logged in, ends after 30 days unused or 180 days at most, and is listed and revoked on the console's Sign-in sessions page. The CLI replaces the file atomically with owner-only permissions and refuses a symlink or a file other users can read."
|
|
4202
5185
|
]
|
|
4203
5186
|
},
|
|
4204
5187
|
{
|
|
@@ -4235,24 +5218,37 @@ var NAME = (noun) => ({ name: `the ${noun}'s name` });
|
|
|
4235
5218
|
var COMMAND_HELP = {
|
|
4236
5219
|
"tonbo login": {
|
|
4237
5220
|
about: [
|
|
4238
|
-
"Prints the login URL and opens the browser at `https://tonbo.dev/cli/login`,
|
|
4239
|
-
"
|
|
4240
|
-
"When the browser is on another computer, the login uses a device code instead: with `--device`, in an SSH session without a display, or when no browser can be opened here. The CLI prints a short code and `https://tonbo.dev/cli/device`; open it in any browser where you are signed in, enter the code, check the computer name it shows
|
|
5221
|
+
"Prints the login URL and opens the browser at `https://tonbo.dev/cli/login`, which names this computer and asks you to sign it in as you. It can then act in every account you belong to, with your role in each, so approve only a computer you are using. The browser returns a one-time code to a local callback on `127.0.0.1`, and the CLI redeems it with its PKCE verifier for the login, tied to this computer's device id.",
|
|
5222
|
+
"There is one login per computer: a new one replaces this computer's earlier login. It ends after 30 days unused or 180 days at most, when you log out, or when you revoke it on the console's Sign-in sessions page; then run `tonbo login` again. With `--account`, commands act in that account by default afterwards, as `tonbo account use` sets.",
|
|
5223
|
+
"When the browser is on another computer, the login uses a device code instead: with `--device`, in an SSH session without a display, or when no browser can be opened here. The CLI prints a short code and `https://tonbo.dev/cli/device`; open it in any browser where you are signed in, enter the code, check the computer name it shows and approve. The CLI polls and saves the same login. The code works once and expires after ten minutes."
|
|
4241
5224
|
],
|
|
4242
|
-
examples: ["tonbo login", "tonbo login --device"]
|
|
4243
|
-
defaults: { "--account": "your personal account" }
|
|
5225
|
+
examples: ["tonbo login", "tonbo login --device", "tonbo login --account acme"]
|
|
4244
5226
|
},
|
|
4245
5227
|
"tonbo logout": {
|
|
4246
5228
|
about: [
|
|
4247
|
-
"
|
|
5229
|
+
"Ends this computer's login for every account and removes the file, which signs `yield` out too. A login an earlier release saved (one key per account in `credentials.json`) has each key revoked as well; one already revoked or expired is skipped. If a revocation fails, the files are still removed, and the CLI says what it could not revoke and exits 1, so you can revoke it on the console's Sign-in sessions page or under API keys. `TONBO_API_KEY` is never touched."
|
|
4248
5230
|
],
|
|
4249
5231
|
examples: ["tonbo logout"]
|
|
4250
5232
|
},
|
|
4251
5233
|
"tonbo account show": {
|
|
4252
5234
|
about: [
|
|
4253
|
-
"Prints the account
|
|
5235
|
+
"Prints the account commands act in: its id, name and kind, your member subject and role in it, and its limits."
|
|
5236
|
+
],
|
|
5237
|
+
examples: ["tonbo account show --json", "tonbo account show --account acme"]
|
|
5238
|
+
},
|
|
5239
|
+
"tonbo account list": {
|
|
5240
|
+
about: [
|
|
5241
|
+
"Lists every account your login reaches, read from Tonbo now: its name, whether it is your personal account or an organization, your role there and its id. `*` marks the account commands act in without `--account`. With `--json` it prints the list, each entry with `default`.",
|
|
5242
|
+
"It needs the login of `tonbo login`: an API key in `TONBO_API_KEY` reaches only its own account, which `tonbo account show` prints."
|
|
4254
5243
|
],
|
|
4255
|
-
examples: ["tonbo account
|
|
5244
|
+
examples: ["tonbo account list", "tonbo account list --json"]
|
|
5245
|
+
},
|
|
5246
|
+
"tonbo account use": {
|
|
5247
|
+
about: [
|
|
5248
|
+
"Makes the account the one commands act in from now on when they name none, on this computer only; `--account` and `TONBO_ACCOUNT` still choose another for one command. The account is checked against the accounts you belong to now. Use your personal account's name to go back to it."
|
|
5249
|
+
],
|
|
5250
|
+
examples: ["tonbo account use acme"],
|
|
5251
|
+
arguments: { account: "an account you belong to, by name or id" }
|
|
4256
5252
|
},
|
|
4257
5253
|
"tonbo region list": {
|
|
4258
5254
|
about: [
|
|
@@ -4479,7 +5475,7 @@ var COMMAND_HELP = {
|
|
|
4479
5475
|
},
|
|
4480
5476
|
"tonbo machine create": {
|
|
4481
5477
|
about: [
|
|
4482
|
-
"Creates a Machine, a VM booted from an image in one region
|
|
5478
|
+
"Creates a Machine, a VM booted from an image in one region. It has no database of its own: its files can last on a Volume (`--volume`), and its program opens a database by URL with a key. The image is a public OCI image with a linux/arm64 variant, from Docker Hub or another public registry (`nginx:1.27`, `ghcr.io/org/app:v1`, or one pinned by digest), or `tonbo.dev/platform`, the platform image. A registry reachable only over IPv6, one named by an IP address, and one that redirects its manifests are refused. The CLI resolves a registry image to its linux/arm64 digest, and the image is pulled before the Machine leaves `creating`.",
|
|
4483
5479
|
"The Machine is served at `https://<name>-<account>.tonbo.sh` on its default port. `--port <port>` serves a private port, which takes a member's token, and `--port <port>:public` an open one, which needs the `machines.public` capability. A command after `--` replaces the image's CMD. The CLI warns on stderr when the Machine has no port, since nothing can wake it then, and when an `--env` key looks secret: env values are readable by every member through the API.",
|
|
4484
5480
|
"The answer is the Machine while it is `creating`. With `--wait` the CLI reads it until it is `active` or `failed`, or `--timeout` passes: a failure prints what to do and exits 1, and a timeout says it is still creating and undoes nothing. A creation the network or a 5xx answer did not confirm is sent at most three times with the same idempotency key, each retry after its `Retry-After` (at most 30 s) or a short backoff; a `name_taken` refusal says which Machine holds the name and how old it is, in case it is yours from a lost reply. In a region not open for Machines the creation is refused with `region_not_offered`, and nothing is created."
|
|
4485
5481
|
],
|
|
@@ -4499,7 +5495,7 @@ var COMMAND_HELP = {
|
|
|
4499
5495
|
},
|
|
4500
5496
|
"tonbo machine show": {
|
|
4501
5497
|
about: [
|
|
4502
|
-
"Prints the Machine's state and what it means (an `active` Machine that is asleep boots on its first request), its hostnames, image and whether it is pulled, the live activation and conditions with what to do about each, when it sleeps,
|
|
5498
|
+
"Prints the Machine's state and what it means (an `active` Machine that is asleep boots on its first request), its hostnames, image and whether it is pulled, the live activation and conditions with what to do about each, when it sleeps, the database it binds (or none) and its logs URL, a link to what its disk keeps, its ETag and who created it. For an `active` Machine it also reads the newest activations and prints how the last one ended. When the live activation runs an older revision than the Machine's, it says which changes already apply and that the rest apply after `tonbo machine sleep <name>`.",
|
|
4503
5499
|
"When the default port is private, it prints the `curl` call that reaches it with a personal key. For one of your computers it prints its system, harnesses, hostname and device id, which only you see."
|
|
4504
5500
|
],
|
|
4505
5501
|
examples: ["tonbo machine show web"],
|
|
@@ -4531,7 +5527,7 @@ var COMMAND_HELP = {
|
|
|
4531
5527
|
},
|
|
4532
5528
|
"tonbo machine delete": {
|
|
4533
5529
|
about: [
|
|
4534
|
-
"Deletes the Machine with its disk
|
|
5530
|
+
"Deletes the Machine with its disk and logs; a Volume or database it binds stays. Its name is free for a new Machine at once. At a terminal it asks you to type the Machine's name first, and sends the ETag it read then.",
|
|
4535
5531
|
"For one of your computers it forgets the computer: it leaves the list at once, and its name is free once the forget completes. Forgetting does not revoke the computer's key, so a computer whose key is still active registers again at its next start; run `yield logout` on it, or `tonbo api-key revoke`, first."
|
|
4536
5532
|
],
|
|
4537
5533
|
examples: ["tonbo machine delete web --yes"],
|
|
@@ -4645,19 +5641,19 @@ function optionDescription(option) {
|
|
|
4645
5641
|
function applyHelp(program) {
|
|
4646
5642
|
const named = /* @__PURE__ */ new Set();
|
|
4647
5643
|
const walk = (command, words) => {
|
|
4648
|
-
const
|
|
5644
|
+
const path4 = words.join(" ");
|
|
4649
5645
|
command.configureHelp({ ...command.configureHelp(), optionDescription });
|
|
4650
|
-
const help = COMMAND_HELP[
|
|
5646
|
+
const help = COMMAND_HELP[path4];
|
|
4651
5647
|
if (help) {
|
|
4652
|
-
named.add(
|
|
5648
|
+
named.add(path4);
|
|
4653
5649
|
for (const [name, text] of Object.entries(help.arguments ?? {})) {
|
|
4654
5650
|
const argument = command.registeredArguments.find((entry) => entry.name() === name);
|
|
4655
|
-
if (!argument) throw new Error(`${
|
|
5651
|
+
if (!argument) throw new Error(`${path4} has no argument ${name}`);
|
|
4656
5652
|
argument.description = text;
|
|
4657
5653
|
}
|
|
4658
5654
|
for (const [flag, text] of Object.entries(help.defaults ?? {})) {
|
|
4659
5655
|
const option = command.options.find((entry) => entry.long === flag);
|
|
4660
|
-
if (!option) throw new Error(`${
|
|
5656
|
+
if (!option) throw new Error(`${path4} has no option ${flag}`);
|
|
4661
5657
|
option.defaultValueDescription = text;
|
|
4662
5658
|
}
|
|
4663
5659
|
command.addHelpText("after", commandHelpText(help));
|
|
@@ -4665,8 +5661,8 @@ function applyHelp(program) {
|
|
|
4665
5661
|
for (const child of command.commands) walk(child, [...words, child.name()]);
|
|
4666
5662
|
};
|
|
4667
5663
|
walk(program, [program.name()]);
|
|
4668
|
-
for (const
|
|
4669
|
-
if (!named.has(
|
|
5664
|
+
for (const path4 of Object.keys(COMMAND_HELP))
|
|
5665
|
+
if (!named.has(path4)) throw new Error(`The help of ${path4} names no command`);
|
|
4670
5666
|
program.addHelpText("after", programHelpText());
|
|
4671
5667
|
return program;
|
|
4672
5668
|
}
|
|
@@ -4700,13 +5696,29 @@ async function terminalAsk(question) {
|
|
|
4700
5696
|
var packageVersion = JSON.parse(
|
|
4701
5697
|
readFileSync(new URL("../../package.json", import.meta.url), "utf8")
|
|
4702
5698
|
);
|
|
4703
|
-
function createDependencies() {
|
|
5699
|
+
function createDependencies(selection = {}) {
|
|
4704
5700
|
const api = apiOrigin();
|
|
4705
|
-
const
|
|
5701
|
+
const account = selection.account?.trim() || process.env.TONBO_ACCOUNT?.trim() || void 0;
|
|
5702
|
+
const credentials = new LoginStore({
|
|
5703
|
+
fetcher: fetch,
|
|
5704
|
+
apiOrigin: api,
|
|
5705
|
+
loginOrigin: loginOrigin(api),
|
|
5706
|
+
account
|
|
5707
|
+
});
|
|
4706
5708
|
return {
|
|
4707
5709
|
api: new ManagementApi(fetch, api),
|
|
4708
5710
|
ask: terminalAsk,
|
|
4709
|
-
auth: new AuthClient(
|
|
5711
|
+
auth: new AuthClient(
|
|
5712
|
+
credentials,
|
|
5713
|
+
fetch,
|
|
5714
|
+
loginOrigin(api),
|
|
5715
|
+
void 0,
|
|
5716
|
+
void 0,
|
|
5717
|
+
void 0,
|
|
5718
|
+
void 0,
|
|
5719
|
+
process.env,
|
|
5720
|
+
{ client: "tonbo", clientVersion: packageVersion.version }
|
|
5721
|
+
),
|
|
4710
5722
|
credentials,
|
|
4711
5723
|
interactive: () => process.stdin.isTTY === true && process.stderr.isTTY === true,
|
|
4712
5724
|
output: (text) => console.log(text),
|
|
@@ -4783,20 +5795,31 @@ function parseUsageSort(value) {
|
|
|
4783
5795
|
throw new InvalidArgumentError(error.message);
|
|
4784
5796
|
}
|
|
4785
5797
|
}
|
|
4786
|
-
function createProgram(
|
|
4787
|
-
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version)
|
|
4788
|
-
|
|
5798
|
+
function createProgram(makeDependencies = createDependencies) {
|
|
5799
|
+
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version).option(
|
|
5800
|
+
"--account <account>",
|
|
5801
|
+
"the account to act in, by name or id: any account you belong to; else TONBO_ACCOUNT, else the default `tonbo account use` set, else your personal account"
|
|
5802
|
+
);
|
|
5803
|
+
const dependencies = () => makeDependencies({ account: program.opts().account });
|
|
5804
|
+
program.command("login").description(
|
|
5805
|
+
"sign this computer in through the browser; it can then act in every account you belong to"
|
|
5806
|
+
).option(
|
|
4789
5807
|
"--device",
|
|
4790
5808
|
"approve with a code in a browser on any computer, as over SSH without a display"
|
|
4791
5809
|
).action(async (options) => {
|
|
4792
|
-
|
|
5810
|
+
const account2 = program.opts().account;
|
|
5811
|
+
await loginCommand(dependencies(), { ...options, account: account2 });
|
|
4793
5812
|
});
|
|
4794
|
-
program.command("logout").description("
|
|
4795
|
-
const account = program.command("account").description("the
|
|
5813
|
+
program.command("logout").description("end this computer's CLI login and remove the saved login").action(async () => logoutCommand(dependencies()));
|
|
5814
|
+
const account = program.command("account").description("the accounts you belong to and the one commands act in");
|
|
4796
5815
|
operationOptions(
|
|
4797
5816
|
account.command("show").description("show the account, your role in it and its limits"),
|
|
4798
5817
|
"getAccount"
|
|
4799
5818
|
).action(async (options) => accountShowCommand(dependencies(), options));
|
|
5819
|
+
account.command("list").description("list the accounts your login reaches, with your role, the default marked").option("--json", "print the accounts as JSON").action(async (options) => accountListCommand(dependencies(), options));
|
|
5820
|
+
account.command("use <account>").description("act in this account, by name or id, from now on unless --account names another").option("--json", "print the account as JSON").action(
|
|
5821
|
+
async (name, options) => accountUseCommand(dependencies(), name, options)
|
|
5822
|
+
);
|
|
4800
5823
|
operationOptions(
|
|
4801
5824
|
program.command("region").description("regions new resources can be created in").command("list").description("list the regions"),
|
|
4802
5825
|
"listRegions"
|
|
@@ -5073,9 +6096,7 @@ function createProgram(dependencies = createDependencies) {
|
|
|
5073
6096
|
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
5074
6097
|
);
|
|
5075
6098
|
operationOptions(
|
|
5076
|
-
machine.command("delete <name>").description(
|
|
5077
|
-
"delete a Machine with its disk, database and logs, or forget one of your computers"
|
|
5078
|
-
).option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
|
|
6099
|
+
machine.command("delete <name>").description("delete a Machine with its disk and logs, or forget one of your computers").option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
|
|
5079
6100
|
"deleteMachine"
|
|
5080
6101
|
).action(
|
|
5081
6102
|
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|