@tonbo/cli 0.11.1 → 0.13.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 +2110 -1025
- 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
|
},
|
|
@@ -592,7 +415,8 @@ var managementOperations = {
|
|
|
592
415
|
"pathParams": [],
|
|
593
416
|
"query": [
|
|
594
417
|
"after",
|
|
595
|
-
"wait"
|
|
418
|
+
"wait",
|
|
419
|
+
"include"
|
|
596
420
|
],
|
|
597
421
|
"idempotencyKey": false
|
|
598
422
|
},
|
|
@@ -765,6 +589,61 @@ var managementOperations = {
|
|
|
765
589
|
"query": [],
|
|
766
590
|
"idempotencyKey": false
|
|
767
591
|
},
|
|
592
|
+
"listYieldQuestions": {
|
|
593
|
+
"method": "GET",
|
|
594
|
+
"path": "/yield/questions",
|
|
595
|
+
"pathParams": [],
|
|
596
|
+
"query": [
|
|
597
|
+
"team",
|
|
598
|
+
"limit"
|
|
599
|
+
],
|
|
600
|
+
"idempotencyKey": false
|
|
601
|
+
},
|
|
602
|
+
"createYieldQuestion": {
|
|
603
|
+
"method": "POST",
|
|
604
|
+
"path": "/yield/questions",
|
|
605
|
+
"pathParams": [],
|
|
606
|
+
"query": [],
|
|
607
|
+
"idempotencyKey": false
|
|
608
|
+
},
|
|
609
|
+
"showYieldQuestion": {
|
|
610
|
+
"method": "GET",
|
|
611
|
+
"path": "/yield/questions/{id}",
|
|
612
|
+
"pathParams": [
|
|
613
|
+
"id"
|
|
614
|
+
],
|
|
615
|
+
"query": [],
|
|
616
|
+
"idempotencyKey": false
|
|
617
|
+
},
|
|
618
|
+
"replyYieldQuestion": {
|
|
619
|
+
"method": "POST",
|
|
620
|
+
"path": "/yield/questions/{id}/replies",
|
|
621
|
+
"pathParams": [
|
|
622
|
+
"id"
|
|
623
|
+
],
|
|
624
|
+
"query": [],
|
|
625
|
+
"idempotencyKey": false
|
|
626
|
+
},
|
|
627
|
+
"closeYieldQuestion": {
|
|
628
|
+
"method": "POST",
|
|
629
|
+
"path": "/yield/questions/{id}/close",
|
|
630
|
+
"pathParams": [
|
|
631
|
+
"id"
|
|
632
|
+
],
|
|
633
|
+
"query": [],
|
|
634
|
+
"idempotencyKey": false
|
|
635
|
+
},
|
|
636
|
+
"listYieldDecisions": {
|
|
637
|
+
"method": "GET",
|
|
638
|
+
"path": "/yield/decisions",
|
|
639
|
+
"pathParams": [],
|
|
640
|
+
"query": [
|
|
641
|
+
"project",
|
|
642
|
+
"path",
|
|
643
|
+
"limit"
|
|
644
|
+
],
|
|
645
|
+
"idempotencyKey": false
|
|
646
|
+
},
|
|
768
647
|
"listDatabases": {
|
|
769
648
|
"method": "GET",
|
|
770
649
|
"path": "/databases",
|
|
@@ -1056,7 +935,7 @@ var apiKeyExpirations = [
|
|
|
1056
935
|
];
|
|
1057
936
|
var credentialsContract = {
|
|
1058
937
|
"version": 1,
|
|
1059
|
-
"description": "
|
|
938
|
+
"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 were retired when those CLIs shipped (founder, 2026-11-03): User Center issues them no more (access-token-v1.json `cli_session.login.retired`) and the live ones were revoked, so no client writes this file any longer. 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
939
|
"path": {
|
|
1061
940
|
"description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
|
|
1062
941
|
"base_env": "XDG_CONFIG_HOME",
|
|
@@ -1095,11 +974,11 @@ var credentialsContract = {
|
|
|
1095
974
|
"login_hint": "tonbo login"
|
|
1096
975
|
},
|
|
1097
976
|
"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.",
|
|
977
|
+
"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
978
|
"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
979
|
"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
980
|
"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."
|
|
981
|
+
"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
982
|
},
|
|
1104
983
|
"current_version": 1,
|
|
1105
984
|
"schema": {
|
|
@@ -1326,864 +1205,1940 @@ var credentialsContract = {
|
|
|
1326
1205
|
]
|
|
1327
1206
|
}
|
|
1328
1207
|
};
|
|
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
|
-
|
|
1208
|
+
var loginContract = {
|
|
1209
|
+
"version": 2,
|
|
1210
|
+
"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.",
|
|
1211
|
+
"path": {
|
|
1212
|
+
"description": "`${XDG_CONFIG_HOME}/tonbo/login.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/login.json`.",
|
|
1213
|
+
"base_env": "XDG_CONFIG_HOME",
|
|
1214
|
+
"fallback_base": [
|
|
1215
|
+
"$HOME",
|
|
1216
|
+
".config"
|
|
1217
|
+
],
|
|
1218
|
+
"relative": [
|
|
1219
|
+
"tonbo",
|
|
1220
|
+
"login.json"
|
|
1221
|
+
]
|
|
1222
|
+
},
|
|
1223
|
+
"permissions": {
|
|
1224
|
+
"description": "POSIX only; a reader on Windows skips the mode checks. The same rules as credentials-v1.json.",
|
|
1225
|
+
"directory_mode": "0700",
|
|
1226
|
+
"file_mode": "0600",
|
|
1227
|
+
"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."
|
|
1228
|
+
},
|
|
1229
|
+
"key_sources": {
|
|
1230
|
+
"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.",
|
|
1231
|
+
"order": [
|
|
1232
|
+
"option",
|
|
1233
|
+
"env",
|
|
1234
|
+
"file"
|
|
1235
|
+
],
|
|
1236
|
+
"env": "TONBO_API_KEY",
|
|
1237
|
+
"files": [
|
|
1238
|
+
"login.json",
|
|
1239
|
+
"credentials.json"
|
|
1240
|
+
]
|
|
1241
|
+
},
|
|
1242
|
+
"account": {
|
|
1243
|
+
"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.",
|
|
1244
|
+
"order": [
|
|
1245
|
+
"option",
|
|
1246
|
+
"env",
|
|
1247
|
+
"default_account_id",
|
|
1248
|
+
"personal_account_id"
|
|
1249
|
+
],
|
|
1250
|
+
"option": "The command's `--account <name|id>` or the library's `account` option.",
|
|
1251
|
+
"env": "TONBO_ACCOUNT",
|
|
1252
|
+
"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.",
|
|
1253
|
+
"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."
|
|
1254
|
+
},
|
|
1255
|
+
"device_file": {
|
|
1256
|
+
"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.",
|
|
1257
|
+
"base_env": "XDG_STATE_HOME",
|
|
1258
|
+
"fallback_base": [
|
|
1259
|
+
"$HOME",
|
|
1260
|
+
".local",
|
|
1261
|
+
"state"
|
|
1262
|
+
],
|
|
1263
|
+
"relative": [
|
|
1264
|
+
"tonbo-yield",
|
|
1265
|
+
"device.json"
|
|
1266
|
+
],
|
|
1267
|
+
"schema": {
|
|
1268
|
+
"type": "object",
|
|
1269
|
+
"required": [
|
|
1270
|
+
"version",
|
|
1271
|
+
"device_id",
|
|
1272
|
+
"machine_id_sha256"
|
|
1273
|
+
],
|
|
1274
|
+
"additionalProperties": true,
|
|
1275
|
+
"properties": {
|
|
1276
|
+
"version": {
|
|
1277
|
+
"const": 1
|
|
1278
|
+
},
|
|
1279
|
+
"device_id": {
|
|
1280
|
+
"type": "string",
|
|
1281
|
+
"pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
|
1282
|
+
"description": "A random UUID made at the first run."
|
|
1283
|
+
},
|
|
1284
|
+
"machine_id_sha256": {
|
|
1285
|
+
"type": [
|
|
1286
|
+
"string",
|
|
1287
|
+
"null"
|
|
1288
|
+
],
|
|
1289
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
1290
|
+
"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."
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
},
|
|
1294
|
+
"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`."
|
|
1295
|
+
},
|
|
1296
|
+
"reader": {
|
|
1297
|
+
"missing": "No file: read credentials.json under credentials-v1.json; with neither, ask the person to run `tonbo login`.",
|
|
1298
|
+
"refuse_symlink": true,
|
|
1299
|
+
"refuse_non_regular_file": true,
|
|
1300
|
+
"refuse_mode_bits": "0077",
|
|
1301
|
+
"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.",
|
|
1302
|
+
"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.",
|
|
1303
|
+
"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.",
|
|
1304
|
+
"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.",
|
|
1305
|
+
"login_hint": "tonbo login"
|
|
1306
|
+
},
|
|
1307
|
+
"current_version": 2,
|
|
1308
|
+
"schema": {
|
|
1309
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
1310
|
+
"$id": "https://tonbo.dev/contracts/cli/credentials-v2.json",
|
|
1311
|
+
"title": "Tonbo CLI login session",
|
|
1312
|
+
"type": "object",
|
|
1313
|
+
"required": [
|
|
1314
|
+
"version",
|
|
1315
|
+
"session",
|
|
1316
|
+
"user_id",
|
|
1317
|
+
"device_id_sha256",
|
|
1318
|
+
"personal_account_id"
|
|
1319
|
+
],
|
|
1320
|
+
"additionalProperties": true,
|
|
1321
|
+
"properties": {
|
|
1322
|
+
"version": {
|
|
1323
|
+
"const": 2
|
|
1324
|
+
},
|
|
1325
|
+
"session": {
|
|
1326
|
+
"type": "object",
|
|
1327
|
+
"required": [
|
|
1328
|
+
"id",
|
|
1329
|
+
"secret",
|
|
1330
|
+
"expires_at"
|
|
1331
|
+
],
|
|
1332
|
+
"additionalProperties": true,
|
|
1333
|
+
"properties": {
|
|
1334
|
+
"id": {
|
|
1335
|
+
"type": "string",
|
|
1336
|
+
"minLength": 1,
|
|
1337
|
+
"description": "The session's id (a UUID)."
|
|
1338
|
+
},
|
|
1339
|
+
"secret": {
|
|
1340
|
+
"type": "string",
|
|
1341
|
+
"pattern": "^tbs_",
|
|
1342
|
+
"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."
|
|
1343
|
+
},
|
|
1344
|
+
"expires_at": {
|
|
1345
|
+
"type": "string",
|
|
1346
|
+
"format": "date-time",
|
|
1347
|
+
"description": "The session's absolute expiry, ISO 8601."
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
},
|
|
1351
|
+
"user_id": {
|
|
1352
|
+
"type": "string",
|
|
1353
|
+
"minLength": 1,
|
|
1354
|
+
"description": "The person the session belongs to (a UUID)."
|
|
1355
|
+
},
|
|
1356
|
+
"device_id_sha256": {
|
|
1357
|
+
"type": "string",
|
|
1358
|
+
"pattern": "^[0-9a-f]{64}$",
|
|
1359
|
+
"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."
|
|
1360
|
+
},
|
|
1361
|
+
"personal_account_id": {
|
|
1362
|
+
"type": "string",
|
|
1363
|
+
"minLength": 1,
|
|
1364
|
+
"description": "The person's personal account (a UUID): the account of a command without --account, TONBO_ACCOUNT or a default."
|
|
1365
|
+
},
|
|
1366
|
+
"default_account_id": {
|
|
1367
|
+
"type": [
|
|
1368
|
+
"string",
|
|
1369
|
+
"null"
|
|
1370
|
+
],
|
|
1371
|
+
"minLength": 1,
|
|
1372
|
+
"description": "The account commands act in without --account or TONBO_ACCOUNT (`tonbo account use`); null or absent for the personal account."
|
|
1373
|
+
},
|
|
1374
|
+
"accounts": {
|
|
1375
|
+
"type": "object",
|
|
1376
|
+
"description": "The person's accounts as GET /api/iam/session last answered them, keyed by account id: a cache of names, never authority.",
|
|
1377
|
+
"additionalProperties": {
|
|
1378
|
+
"type": "object",
|
|
1379
|
+
"required": [
|
|
1380
|
+
"name",
|
|
1381
|
+
"kind"
|
|
1382
|
+
],
|
|
1383
|
+
"properties": {
|
|
1384
|
+
"name": {
|
|
1385
|
+
"type": "string",
|
|
1386
|
+
"minLength": 1,
|
|
1387
|
+
"description": "The account's name, which --account and TONBO_ACCOUNT take."
|
|
1388
|
+
},
|
|
1389
|
+
"kind": {
|
|
1390
|
+
"type": "string",
|
|
1391
|
+
"pattern": "^(user|organization)$",
|
|
1392
|
+
"description": "`user` for the personal account."
|
|
1393
|
+
},
|
|
1394
|
+
"role": {
|
|
1395
|
+
"type": [
|
|
1396
|
+
"string",
|
|
1397
|
+
"null"
|
|
1398
|
+
],
|
|
1399
|
+
"description": "The person's role when the cache was written; display only."
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
},
|
|
1406
|
+
"examples": {
|
|
1407
|
+
"usable": [
|
|
1408
|
+
{
|
|
1409
|
+
"version": 2,
|
|
1410
|
+
"session": {
|
|
1411
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1412
|
+
"secret": "tbs_secret",
|
|
1413
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1414
|
+
},
|
|
1415
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1416
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1417
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1418
|
+
},
|
|
1419
|
+
{
|
|
1420
|
+
"version": 2,
|
|
1421
|
+
"session": {
|
|
1422
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1423
|
+
"secret": "tbs_secret",
|
|
1424
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1425
|
+
},
|
|
1426
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1427
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1428
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001",
|
|
1429
|
+
"default_account_id": "10000000-0000-4000-8000-0000000000a1",
|
|
1430
|
+
"accounts": {
|
|
1431
|
+
"10000000-0000-4000-8000-000000000001": {
|
|
1432
|
+
"name": "sam",
|
|
1433
|
+
"kind": "user",
|
|
1434
|
+
"role": "owner"
|
|
1435
|
+
},
|
|
1436
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
1437
|
+
"name": "acme",
|
|
1438
|
+
"kind": "organization",
|
|
1439
|
+
"role": "member"
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
],
|
|
1444
|
+
"unusable": [
|
|
1445
|
+
{
|
|
1446
|
+
"version": 1,
|
|
1447
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
1448
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
1449
|
+
"key": "tbo_secret",
|
|
1450
|
+
"expires_at": null
|
|
1451
|
+
},
|
|
1452
|
+
{
|
|
1453
|
+
"version": 2,
|
|
1454
|
+
"session": {
|
|
1455
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1456
|
+
"secret": "tbo_secret",
|
|
1457
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1458
|
+
},
|
|
1459
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1460
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1461
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1462
|
+
},
|
|
1463
|
+
{
|
|
1464
|
+
"version": 2,
|
|
1465
|
+
"session": {
|
|
1466
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1467
|
+
"secret": "tbs_secret",
|
|
1468
|
+
"expires_at": null
|
|
1469
|
+
},
|
|
1470
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1471
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1472
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
"version": 2,
|
|
1476
|
+
"session": {
|
|
1477
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1478
|
+
"secret": "tbs_secret",
|
|
1479
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1480
|
+
},
|
|
1481
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1482
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
"version": 2,
|
|
1486
|
+
"session": {
|
|
1487
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1488
|
+
"secret": "tbs_secret",
|
|
1489
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1490
|
+
},
|
|
1491
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1492
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1493
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001",
|
|
1494
|
+
"accounts": {
|
|
1495
|
+
"10000000-0000-4000-8000-0000000000a1": {
|
|
1496
|
+
"name": "acme",
|
|
1497
|
+
"kind": "team"
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
"version": 3,
|
|
1503
|
+
"session": {
|
|
1504
|
+
"id": "30000000-0000-4000-8000-000000000003",
|
|
1505
|
+
"secret": "tbs_secret",
|
|
1506
|
+
"expires_at": "2027-05-02T00:00:00.000Z"
|
|
1507
|
+
},
|
|
1508
|
+
"user_id": "40000000-0000-4000-8000-000000000004",
|
|
1509
|
+
"device_id_sha256": "342ace7df588b4b741e10d57aeab9d9a79dab7cc931ddc50204c5bf381c96415",
|
|
1510
|
+
"personal_account_id": "10000000-0000-4000-8000-000000000001"
|
|
1511
|
+
}
|
|
1512
|
+
]
|
|
1364
1513
|
}
|
|
1365
|
-
|
|
1366
|
-
|
|
1514
|
+
};
|
|
1515
|
+
var cliSessionContract = {
|
|
1516
|
+
"prefix": "tbs_",
|
|
1517
|
+
"grant": "cli_session",
|
|
1518
|
+
"grantParameter": "grant",
|
|
1519
|
+
"exchangePath": "/api/iam/token",
|
|
1520
|
+
"sessionPath": "/api/iam/session",
|
|
1521
|
+
"audiences": {
|
|
1522
|
+
"streams": "https://streams.tonbo.dev",
|
|
1523
|
+
"management": "https://api.tonbo.dev"
|
|
1524
|
+
},
|
|
1525
|
+
"idleDays": 30
|
|
1526
|
+
};
|
|
1527
|
+
var machineIdleActions = [
|
|
1528
|
+
"suspend",
|
|
1529
|
+
"release"
|
|
1530
|
+
];
|
|
1531
|
+
var machinePortAccess = [
|
|
1532
|
+
"private",
|
|
1533
|
+
"public"
|
|
1534
|
+
];
|
|
1535
|
+
var machineTimings = {
|
|
1536
|
+
"pollAfterSeconds": 2,
|
|
1537
|
+
"sleepPollAfterSeconds": 5,
|
|
1538
|
+
"wakeHoldSeconds": 25,
|
|
1539
|
+
"earlyReclaimSeconds": 120
|
|
1540
|
+
};
|
|
1541
|
+
|
|
1542
|
+
// ../../packages/tonbo-cli-core/dist/src/bearer.js
|
|
1543
|
+
var MANAGEMENT_AUDIENCE = cliSessionContract.audiences.management;
|
|
1544
|
+
function isAccountBearer(bearer) {
|
|
1545
|
+
return typeof bearer !== "string";
|
|
1546
|
+
}
|
|
1547
|
+
function managementBearer(bearer) {
|
|
1548
|
+
return isAccountBearer(bearer) ? bearer.token(MANAGEMENT_AUDIENCE) : Promise.resolve(bearer);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// ../../packages/tonbo-cli-core/dist/src/access-token.js
|
|
1552
|
+
var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
|
|
1553
|
+
var GIT_AUDIENCE = "https://git.tonbo.dev";
|
|
1554
|
+
var AccessTokenError = class extends Error {
|
|
1555
|
+
status;
|
|
1556
|
+
retryAfterMs;
|
|
1557
|
+
code;
|
|
1558
|
+
constructor(message, status, retryAfterMs = null, code = null) {
|
|
1559
|
+
super(message);
|
|
1560
|
+
this.status = status;
|
|
1561
|
+
this.retryAfterMs = retryAfterMs;
|
|
1562
|
+
this.code = code;
|
|
1563
|
+
}
|
|
1564
|
+
get retryable() {
|
|
1565
|
+
return this.status === null || this.status === 429 || this.status >= 500;
|
|
1566
|
+
}
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
// ../../packages/tonbo-cli-core/dist/src/http.js
|
|
1570
|
+
var HttpError = class extends Error {
|
|
1571
|
+
status;
|
|
1572
|
+
body;
|
|
1573
|
+
requestId;
|
|
1574
|
+
constructor(message, status, body, requestId) {
|
|
1575
|
+
super(message);
|
|
1576
|
+
this.status = status;
|
|
1577
|
+
this.body = body;
|
|
1578
|
+
this.requestId = requestId;
|
|
1579
|
+
}
|
|
1580
|
+
/** The problem `code` of a Management API refusal, such as `unauthenticated`. */
|
|
1581
|
+
get code() {
|
|
1582
|
+
const record = this.body && typeof this.body === "object" ? this.body : {};
|
|
1583
|
+
const code = record.code;
|
|
1584
|
+
return typeof code === "string" ? code : null;
|
|
1585
|
+
}
|
|
1586
|
+
};
|
|
1587
|
+
async function requestJson(fetcher, url, init = {}) {
|
|
1588
|
+
const response = await fetcher(url, init);
|
|
1589
|
+
const body = await response.json().catch(() => null);
|
|
1590
|
+
if (response.ok)
|
|
1591
|
+
return body;
|
|
1592
|
+
const record = body && typeof body === "object" ? body : {};
|
|
1593
|
+
const reason = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
|
|
1594
|
+
const requestId = response.headers.get("x-request-id") ?? (typeof record.request_id === "string" ? record.request_id : null);
|
|
1595
|
+
const issues = Array.isArray(record.errors) ? record.errors.filter((issue) => typeof issue?.field === "string" && typeof issue.code === "string").map((issue) => `${issue.field}: ${issue.code}`) : [];
|
|
1596
|
+
const message = [
|
|
1597
|
+
reason ?? `Request failed with HTTP ${response.status}.`,
|
|
1598
|
+
...issues.length ? [`(${issues.join(", ")})`] : []
|
|
1599
|
+
].join(" ");
|
|
1600
|
+
throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// ../../packages/tonbo-cli-core/dist/src/api.js
|
|
1604
|
+
var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
|
|
1605
|
+
var DEFAULT_LOGIN_HINT2 = credentialsContract.reader.login_hint;
|
|
1606
|
+
var ExchangeRefusal = class extends HttpError {
|
|
1607
|
+
};
|
|
1608
|
+
async function presented(key) {
|
|
1609
|
+
try {
|
|
1610
|
+
return await managementBearer(key);
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
if (!(error instanceof AccessTokenError))
|
|
1613
|
+
throw error;
|
|
1614
|
+
throw new ExchangeRefusal(error.message, error.status ?? 503, { code: error.code }, null);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
var ManagementApi = class {
|
|
1618
|
+
fetcher;
|
|
1619
|
+
apiOrigin;
|
|
1620
|
+
loginHint;
|
|
1621
|
+
pause;
|
|
1622
|
+
constructor(fetcher, apiOrigin2 = DEFAULT_API_ORIGIN, loginHint = DEFAULT_LOGIN_HINT2, pause = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))) {
|
|
1623
|
+
this.fetcher = fetcher;
|
|
1624
|
+
this.apiOrigin = apiOrigin2;
|
|
1625
|
+
this.loginHint = loginHint;
|
|
1626
|
+
this.pause = pause;
|
|
1627
|
+
}
|
|
1628
|
+
/** The answer's `data`. A refusal throws an HttpError naming the request id. */
|
|
1629
|
+
async call(operationId, key, request = {}) {
|
|
1630
|
+
return (await this.request(operationId, key, request)).data;
|
|
1631
|
+
}
|
|
1632
|
+
/** One page of a list operation: its `data` and `next_cursor`, with the
|
|
1633
|
+
* operation's other query parameters, if any. */
|
|
1634
|
+
page(operationId, key, { limit, cursor } = {}, query = {}) {
|
|
1635
|
+
return this.request(operationId, key, { query: { ...query, limit, cursor } });
|
|
1636
|
+
}
|
|
1637
|
+
async request(operationId, key, request, seen) {
|
|
1638
|
+
const attempt = async () => this.requestWith(operationId, await presented(key), request, seen);
|
|
1367
1639
|
try {
|
|
1368
|
-
await
|
|
1369
|
-
parsed = JSON.parse(await readFile(this.filename, "utf8"));
|
|
1640
|
+
return await attempt();
|
|
1370
1641
|
} catch (error) {
|
|
1371
|
-
if (error.
|
|
1372
|
-
|
|
1373
|
-
|
|
1642
|
+
if (!isAccountBearer(key) || !(error instanceof HttpError) || error.status !== 401 || error instanceof ExchangeRefusal)
|
|
1643
|
+
throw error;
|
|
1644
|
+
key.invalidate(MANAGEMENT_AUDIENCE);
|
|
1645
|
+
return attempt();
|
|
1374
1646
|
}
|
|
1375
|
-
if (!isStoredLogin(parsed))
|
|
1376
|
-
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
1377
|
-
return parsed;
|
|
1378
1647
|
}
|
|
1379
|
-
async
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1648
|
+
async requestWith(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }, seen) {
|
|
1649
|
+
const operation = managementOperations[operationId];
|
|
1650
|
+
const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
1651
|
+
const value = params[name];
|
|
1652
|
+
if (value === void 0)
|
|
1653
|
+
throw new Error(`${operationId} needs the path parameter ${name}.`);
|
|
1654
|
+
return encodeURIComponent(value);
|
|
1655
|
+
});
|
|
1656
|
+
const search = new URLSearchParams();
|
|
1657
|
+
for (const [name, value] of Object.entries(query)) {
|
|
1658
|
+
if (value === void 0)
|
|
1659
|
+
continue;
|
|
1660
|
+
if (!operation.query.includes(name))
|
|
1661
|
+
throw new Error(`${operationId} takes no query parameter ${name}.`);
|
|
1662
|
+
search.set(name, String(value));
|
|
1663
|
+
}
|
|
1664
|
+
if (operation.idempotencyKey && !idempotencyKey)
|
|
1665
|
+
throw new Error(`${operationId} needs an Idempotency-Key.`);
|
|
1666
|
+
const headers = { authorization: `Bearer ${key}` };
|
|
1667
|
+
if (body !== void 0)
|
|
1668
|
+
headers["content-type"] = "application/json";
|
|
1669
|
+
if (idempotencyKey)
|
|
1670
|
+
headers["idempotency-key"] = idempotencyKey;
|
|
1671
|
+
if (ifMatch)
|
|
1672
|
+
headers["if-match"] = ifMatch;
|
|
1673
|
+
const queryString = search.toString();
|
|
1674
|
+
const url = `${this.apiOrigin}${managementApiBasePath}${path4}${queryString ? `?${queryString}` : ""}`;
|
|
1675
|
+
try {
|
|
1676
|
+
const fetcher = seen ? async (input, init) => {
|
|
1677
|
+
const response = await this.fetcher(input, init);
|
|
1678
|
+
seen(response);
|
|
1679
|
+
return response;
|
|
1680
|
+
} : this.fetcher;
|
|
1681
|
+
return await requestJson(fetcher, url, {
|
|
1682
|
+
method: operation.method,
|
|
1683
|
+
headers,
|
|
1684
|
+
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
1685
|
+
});
|
|
1686
|
+
} catch (error) {
|
|
1687
|
+
if (error instanceof HttpError && error.status === 401)
|
|
1688
|
+
throw new HttpError(`${error.message} Run \`${this.loginHint}\`.`, error.status, error.body, error.requestId);
|
|
1689
|
+
throw error;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
getAccount(key) {
|
|
1693
|
+
return this.call("getAccount", key);
|
|
1694
|
+
}
|
|
1695
|
+
listRegions(key) {
|
|
1696
|
+
return this.call("listRegions", key);
|
|
1697
|
+
}
|
|
1698
|
+
listMembers(key, page) {
|
|
1699
|
+
return this.page("listMembers", key, page);
|
|
1700
|
+
}
|
|
1701
|
+
listGitWorkspaces(key, page) {
|
|
1702
|
+
return this.page("listGitWorkspaces", key, page);
|
|
1703
|
+
}
|
|
1704
|
+
updateMember(key, subject, change) {
|
|
1705
|
+
return this.call("updateMember", key, { params: { subject }, body: change });
|
|
1706
|
+
}
|
|
1707
|
+
/** A replay with the same idempotency key returns the Agent the first
|
|
1708
|
+
* request created, so an unconfirmed creation is retried with that key. */
|
|
1709
|
+
createAgent(key, request, idempotencyKey) {
|
|
1710
|
+
return this.call("createAgent", key, {
|
|
1711
|
+
body: withoutUndefined(request),
|
|
1712
|
+
idempotencyKey
|
|
1713
|
+
}).catch(unconfirmed("Agent creation", idempotencyKey, "the same name, role and region"));
|
|
1714
|
+
}
|
|
1715
|
+
listApiKeys(key, page) {
|
|
1716
|
+
return this.page("listApiKeys", key, page);
|
|
1717
|
+
}
|
|
1718
|
+
/** Not idempotent: the secret is never stored, so a replay could not
|
|
1719
|
+
* return it, and a blind retry of a live name answers 409. */
|
|
1720
|
+
createApiKey(key, request) {
|
|
1721
|
+
return this.call("createApiKey", key, { body: withoutUndefined(request) });
|
|
1722
|
+
}
|
|
1723
|
+
getApiKey(key, keyId) {
|
|
1724
|
+
return this.call("getApiKey", key, { params: { key_id: keyId } });
|
|
1725
|
+
}
|
|
1726
|
+
revokeApiKey(key, keyId) {
|
|
1727
|
+
return this.call("revokeApiKey", key, {
|
|
1728
|
+
params: { key_id: keyId }
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
listBuckets(key, page) {
|
|
1732
|
+
return this.page("listBuckets", key, page);
|
|
1733
|
+
}
|
|
1734
|
+
/** A replay with the same idempotency key returns the bucket the first
|
|
1735
|
+
* request created. */
|
|
1736
|
+
createBucket(key, request, idempotencyKey) {
|
|
1737
|
+
return this.call("createBucket", key, {
|
|
1738
|
+
body: withoutUndefined(request),
|
|
1739
|
+
idempotencyKey
|
|
1740
|
+
}).catch(unconfirmed("Bucket creation", idempotencyKey, "the same name and region"));
|
|
1741
|
+
}
|
|
1742
|
+
getBucket(key, name) {
|
|
1743
|
+
return this.call("getBucket", key, { params: { name } });
|
|
1744
|
+
}
|
|
1745
|
+
updateBucket(key, name, change) {
|
|
1746
|
+
return this.call("updateBucket", key, { params: { name }, body: change });
|
|
1747
|
+
}
|
|
1748
|
+
deleteBucket(key, name) {
|
|
1749
|
+
return this.call("deleteBucket", key, { params: { name } });
|
|
1750
|
+
}
|
|
1751
|
+
listDatabases(key, page, prefix) {
|
|
1752
|
+
return this.page("listDatabases", key, page, { prefix });
|
|
1753
|
+
}
|
|
1754
|
+
/** A replay with the same idempotency key returns the database the first
|
|
1755
|
+
* request created. */
|
|
1756
|
+
createDatabase(key, request, idempotencyKey) {
|
|
1757
|
+
return this.call("createDatabase", key, {
|
|
1758
|
+
body: withoutUndefined(request),
|
|
1759
|
+
idempotencyKey
|
|
1760
|
+
}).catch(unconfirmed("Database creation", idempotencyKey, request.expires_at ? `the same name, region and --expires-at ${request.expires_at}` : "the same name and region"));
|
|
1761
|
+
}
|
|
1762
|
+
getDatabase(key, name) {
|
|
1763
|
+
return this.call("getDatabase", key, { params: { name } });
|
|
1764
|
+
}
|
|
1765
|
+
updateDatabase(key, name, change) {
|
|
1766
|
+
return this.call("updateDatabase", key, { params: { name }, body: change });
|
|
1767
|
+
}
|
|
1768
|
+
deleteDatabase(key, name) {
|
|
1769
|
+
return this.call("deleteDatabase", key, { params: { name } });
|
|
1770
|
+
}
|
|
1771
|
+
/** A database's writes per hour or day and its size at each step's end. */
|
|
1772
|
+
getDatabaseActivity(key, name, query = {}) {
|
|
1773
|
+
return this.call("getDatabaseActivity", key, { params: { name }, query });
|
|
1774
|
+
}
|
|
1775
|
+
/** The whole usage page: its rows, `other`, prices and checkpoint. */
|
|
1776
|
+
listDatabaseUsage(key, page, query = {}) {
|
|
1777
|
+
return this.page("listDatabaseUsage", key, page, query);
|
|
1778
|
+
}
|
|
1779
|
+
listStreams(key, page, prefix) {
|
|
1780
|
+
return this.page("listStreams", key, page, { prefix });
|
|
1781
|
+
}
|
|
1782
|
+
/** A replay with the same idempotency key returns the stream the first
|
|
1783
|
+
* request created. */
|
|
1784
|
+
createStream(key, request, idempotencyKey) {
|
|
1785
|
+
return this.call("createStream", key, {
|
|
1786
|
+
body: withoutUndefined(request),
|
|
1787
|
+
idempotencyKey
|
|
1788
|
+
}).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"));
|
|
1789
|
+
}
|
|
1790
|
+
getStream(key, name) {
|
|
1791
|
+
return this.call("getStream", key, { params: { name } });
|
|
1792
|
+
}
|
|
1793
|
+
updateStream(key, name, change) {
|
|
1794
|
+
return this.call("updateStream", key, { params: { name }, body: change });
|
|
1795
|
+
}
|
|
1796
|
+
deleteStream(key, name) {
|
|
1797
|
+
return this.call("deleteStream", key, { params: { name } });
|
|
1798
|
+
}
|
|
1799
|
+
/** A stream's appends per hour or day and its size at each step's end. */
|
|
1800
|
+
getStreamActivity(key, name, query = {}) {
|
|
1801
|
+
return this.call("getStreamActivity", key, { params: { name }, query });
|
|
1802
|
+
}
|
|
1803
|
+
/** The whole usage page: its rows, `other`, prices and checkpoint. */
|
|
1804
|
+
listStreamUsage(key, page, query = {}) {
|
|
1805
|
+
return this.page("listStreamUsage", key, page, query);
|
|
1806
|
+
}
|
|
1807
|
+
getUsage(key, period) {
|
|
1808
|
+
return this.call("getUsage", key, { query: { period } });
|
|
1809
|
+
}
|
|
1810
|
+
getUsageSeries(key, query = {}) {
|
|
1811
|
+
return this.call("getUsageSeries", key, { query });
|
|
1812
|
+
}
|
|
1813
|
+
/** The account's writes of yesterday and today, hourly, per lane. */
|
|
1814
|
+
getUsageToday(key) {
|
|
1815
|
+
return this.call("getUsageToday", key);
|
|
1816
|
+
}
|
|
1817
|
+
listMachines(key, page) {
|
|
1818
|
+
return this.page("listMachines", key, page);
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* A replay with the same idempotency key returns the Machine the first
|
|
1822
|
+
* request created, so a transport failure or a 5xx answer is retried here
|
|
1823
|
+
* with the same key, at most `CREATE_ATTEMPTS` times, after the answer's
|
|
1824
|
+
* Retry-After (bounded) or a short backoff. A refusal is final; a create
|
|
1825
|
+
* still unconfirmed after the last attempt names the key to retry with.
|
|
1826
|
+
* The answer carries the Machine's `Location` and, while it is creating,
|
|
1827
|
+
* the `Retry-After` to read it again after (machine-v2.json
|
|
1828
|
+
* `creation.create_status`).
|
|
1829
|
+
*/
|
|
1830
|
+
async createMachine(key, request, idempotencyKey) {
|
|
1831
|
+
let last;
|
|
1832
|
+
for (let attempt = 1; attempt <= CREATE_ATTEMPTS; attempt++) {
|
|
1833
|
+
const headers = {
|
|
1834
|
+
location: null,
|
|
1835
|
+
retryAfter: null
|
|
1836
|
+
};
|
|
1837
|
+
try {
|
|
1838
|
+
const answer = await this.request("createMachine", key, { body: withoutUndefined(request), idempotencyKey }, (response) => {
|
|
1839
|
+
headers.location = response.headers.get("location");
|
|
1840
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1841
|
+
});
|
|
1842
|
+
return {
|
|
1843
|
+
machine: answer.data,
|
|
1844
|
+
location: headers.location,
|
|
1845
|
+
retryAfterSeconds: retryAfterSeconds(headers.retryAfter)
|
|
1846
|
+
};
|
|
1847
|
+
} catch (error) {
|
|
1848
|
+
if (error instanceof HttpError && error.status < 500)
|
|
1849
|
+
throw error;
|
|
1850
|
+
last = error;
|
|
1851
|
+
if (attempt === CREATE_ATTEMPTS)
|
|
1852
|
+
break;
|
|
1853
|
+
const wait = retryAfterSeconds(headers.retryAfter) ?? attempt;
|
|
1854
|
+
await this.pause(Math.min(wait, CREATE_RETRY_MAX_WAIT_SECONDS) * 1e3);
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return unconfirmed("Machine creation", idempotencyKey, "the same arguments")(last);
|
|
1858
|
+
}
|
|
1859
|
+
/** The name of the Machine a create's `Location` names, when it is this
|
|
1860
|
+
* client's origin and `getMachine`'s path; null otherwise, so the key is
|
|
1861
|
+
* never sent to another origin and the caller reads the Machine by the
|
|
1862
|
+
* name it created. */
|
|
1863
|
+
machineNameAt(location) {
|
|
1864
|
+
if (!location)
|
|
1865
|
+
return null;
|
|
1866
|
+
let url;
|
|
1867
|
+
try {
|
|
1868
|
+
url = new URL(location, this.apiOrigin);
|
|
1869
|
+
} catch {
|
|
1870
|
+
return null;
|
|
1871
|
+
}
|
|
1872
|
+
if (url.origin !== new URL(this.apiOrigin).origin || url.search || url.hash)
|
|
1873
|
+
return null;
|
|
1874
|
+
const prefix = `${managementApiBasePath}${managementOperations.getMachine.path.replace("{name}", "")}`;
|
|
1875
|
+
if (!url.pathname.startsWith(prefix))
|
|
1876
|
+
return null;
|
|
1877
|
+
const segment = url.pathname.slice(prefix.length);
|
|
1878
|
+
if (!segment || segment.includes("/"))
|
|
1879
|
+
return null;
|
|
1880
|
+
try {
|
|
1881
|
+
return decodeURIComponent(segment);
|
|
1882
|
+
} catch {
|
|
1883
|
+
return null;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
getMachine(key, name) {
|
|
1887
|
+
return this.call("getMachine", key, { params: { name } });
|
|
1888
|
+
}
|
|
1889
|
+
/** The Machine with its ETag, which is opaque (machine-v2.json `etag`)
|
|
1890
|
+
* and is sent back as it was read. */
|
|
1891
|
+
async getMachineWithEtag(key, name) {
|
|
1892
|
+
const headers = { etag: null };
|
|
1893
|
+
const answer = await this.request("getMachine", key, { params: { name } }, (response) => {
|
|
1894
|
+
headers.etag = response.headers.get("etag");
|
|
1895
|
+
});
|
|
1896
|
+
return { machine: answer.data, etag: headers.etag };
|
|
1897
|
+
}
|
|
1898
|
+
/** Written only to the Machine and revision `etag` names, the ETag as read
|
|
1899
|
+
* (machine-v2 requires If-Match on update); a stale one is refused with
|
|
1900
|
+
* `precondition_failed`. */
|
|
1901
|
+
updateMachine(key, name, etag, change) {
|
|
1902
|
+
return this.call("updateMachine", key, {
|
|
1903
|
+
params: { name },
|
|
1904
|
+
body: withoutUndefined(change),
|
|
1905
|
+
ifMatch: etag
|
|
1906
|
+
});
|
|
1907
|
+
}
|
|
1908
|
+
/** With `etag`, only the Machine that ETag names, at that revision, is put
|
|
1909
|
+
* to sleep (machine-v2.json `etag.if_match.sleep` is optional). A 202
|
|
1910
|
+
* carries the `Retry-After` to read the Machine again after
|
|
1911
|
+
* (machine-v2.json `sleep.poll_after_seconds`); a 200 carries none. */
|
|
1912
|
+
async sleepMachine(key, name, etag) {
|
|
1913
|
+
const headers = { retryAfter: null };
|
|
1914
|
+
const answer = await this.request("sleepMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1915
|
+
headers.retryAfter = response.headers.get("retry-after");
|
|
1916
|
+
});
|
|
1917
|
+
return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
|
|
1918
|
+
}
|
|
1919
|
+
/** The Machine being deleted or the device forgotten, with its kind; the
|
|
1920
|
+
* Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
|
|
1921
|
+
* says whether a name was released (`managed`: its create never reached its
|
|
1922
|
+
* region) or a device was forgotten (`byoc`: its region held no row). With
|
|
1923
|
+
* `etag`, only the Machine that ETag names is deleted
|
|
1924
|
+
* (machine-v2.json `etag.if_match.delete` is optional). */
|
|
1925
|
+
async deleteMachine(key, name, etag) {
|
|
1926
|
+
const headers = { kind: null };
|
|
1927
|
+
const answer = await this.request("deleteMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
|
|
1928
|
+
headers.kind = response.headers.get("x-tonbo-machine-kind");
|
|
1929
|
+
});
|
|
1930
|
+
if (answer?.data)
|
|
1931
|
+
return { machine: answer.data, kind: answer.data.kind };
|
|
1932
|
+
return { machine: null, kind: headers.kind === "byoc" ? "byoc" : "managed" };
|
|
1933
|
+
}
|
|
1934
|
+
/** One page of a managed Machine's activations, newest first; the cursor
|
|
1935
|
+
* pages that Machine only. */
|
|
1936
|
+
listMachineActivations(key, name, { limit, cursor } = {}) {
|
|
1937
|
+
return this.request("listMachineActivations", key, {
|
|
1938
|
+
params: { name },
|
|
1939
|
+
query: { limit, cursor }
|
|
1940
|
+
});
|
|
1941
|
+
}
|
|
1942
|
+
/** The whole answer: the shapes and the default one. */
|
|
1943
|
+
listMachineShapes(key) {
|
|
1944
|
+
return this.request("listMachineShapes", key, {});
|
|
1945
|
+
}
|
|
1946
|
+
/** Every Volume of the account, and the regions that could not be read. */
|
|
1947
|
+
listVolumes(key) {
|
|
1948
|
+
return this.request("listVolumes", key, {});
|
|
1403
1949
|
}
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1950
|
+
/** A replay with the same idempotency key answers the Volume the first
|
|
1951
|
+
* request created, so an unconfirmed create names the key to retry with. */
|
|
1952
|
+
createVolume(key, request, idempotencyKey) {
|
|
1953
|
+
return this.call("createVolume", key, { body: request, idempotencyKey }).catch(unconfirmed("Volume creation", idempotencyKey, "the same name, region and size"));
|
|
1407
1954
|
}
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
"const",
|
|
1411
|
-
"type",
|
|
1412
|
-
"minLength",
|
|
1413
|
-
"pattern",
|
|
1414
|
-
"format",
|
|
1415
|
-
"description",
|
|
1416
|
-
"additionalProperties"
|
|
1417
|
-
]);
|
|
1418
|
-
function isPlainObject(value) {
|
|
1419
|
-
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
1420
|
-
}
|
|
1421
|
-
function matchesObject(value, rule) {
|
|
1422
|
-
if (rule.type !== "object" || !isPlainObject(value))
|
|
1423
|
-
return false;
|
|
1424
|
-
if (!rule.required.every((name) => Object.hasOwn(value, name)))
|
|
1425
|
-
return false;
|
|
1426
|
-
return Object.entries(rule.properties).every(([name, inner]) => !Object.hasOwn(value, name) || matchesRule(value[name], inner));
|
|
1427
|
-
}
|
|
1428
|
-
function matchesRule(value, rule) {
|
|
1429
|
-
if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
|
|
1430
|
-
return false;
|
|
1431
|
-
if ("const" in rule)
|
|
1432
|
-
return value === rule.const;
|
|
1433
|
-
const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
|
|
1434
|
-
if (types.includes("object")) {
|
|
1435
|
-
if (!isPlainObject(value) || !rule.additionalProperties)
|
|
1436
|
-
return false;
|
|
1437
|
-
const entries = rule.additionalProperties;
|
|
1438
|
-
return Object.entries(value).every(([key, entry]) => key.length > 0 && matchesObject(entry, entries));
|
|
1955
|
+
getVolume(key, name) {
|
|
1956
|
+
return this.call("getVolume", key, { params: { name } });
|
|
1439
1957
|
}
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
if (
|
|
1449
|
-
return
|
|
1450
|
-
return
|
|
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));
|
|
1958
|
+
/** The Volume being deleted; refused (409) while it is bound. */
|
|
1959
|
+
deleteVolume(key, name) {
|
|
1960
|
+
return this.call("deleteVolume", key, { params: { name } });
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
var CREATE_ATTEMPTS = 3;
|
|
1964
|
+
var CREATE_RETRY_MAX_WAIT_SECONDS = 30;
|
|
1965
|
+
function retryAfterSeconds(value) {
|
|
1966
|
+
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
1967
|
+
return null;
|
|
1968
|
+
return Number(value.trim());
|
|
1463
1969
|
}
|
|
1464
|
-
function
|
|
1465
|
-
|
|
1466
|
-
...entry,
|
|
1467
|
-
account_id,
|
|
1468
|
-
kind: entry.kind ?? null,
|
|
1469
|
-
name: entry.name ?? null
|
|
1470
|
-
}));
|
|
1471
|
-
if (!entries.some((entry) => entry.account_id === login.account_id))
|
|
1472
|
-
entries.unshift({
|
|
1473
|
-
account_id: login.account_id,
|
|
1474
|
-
key_id: login.key_id,
|
|
1475
|
-
key: login.key,
|
|
1476
|
-
expires_at: login.expires_at,
|
|
1477
|
-
kind: null,
|
|
1478
|
-
name: null
|
|
1479
|
-
});
|
|
1480
|
-
return entries;
|
|
1970
|
+
function withoutUndefined(value) {
|
|
1971
|
+
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
1481
1972
|
}
|
|
1482
|
-
function
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
accounts[account_id2] = rest2;
|
|
1488
|
-
}
|
|
1489
|
-
const { account_id, ...rest } = entry;
|
|
1490
|
-
accounts[account_id] = rest;
|
|
1491
|
-
const top = options.current || !file ? entry : file;
|
|
1492
|
-
return {
|
|
1493
|
-
version: 1,
|
|
1494
|
-
account_id: top.account_id,
|
|
1495
|
-
key_id: top.key_id,
|
|
1496
|
-
key: top.key,
|
|
1497
|
-
expires_at: top.expires_at,
|
|
1498
|
-
login_secret: options.loginSecret,
|
|
1499
|
-
accounts
|
|
1973
|
+
function unconfirmed(what, idempotencyKey, same) {
|
|
1974
|
+
return (cause) => {
|
|
1975
|
+
if (cause instanceof HttpError && cause.status < 500)
|
|
1976
|
+
throw cause;
|
|
1977
|
+
throw new Error(`${what} was not confirmed: ${cause instanceof Error ? cause.message : String(cause)} Retry with --idempotency-key ${idempotencyKey} and ${same}.`, { cause });
|
|
1500
1978
|
};
|
|
1501
1979
|
}
|
|
1502
|
-
async function preparePrivateDirectory(directory) {
|
|
1503
|
-
const stat = await lstat(directory);
|
|
1504
|
-
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
1505
|
-
throw new Error("Tonbo config directory must be a real directory.");
|
|
1506
|
-
if (process.platform !== "win32")
|
|
1507
|
-
await chmod(directory, DIRECTORY_MODE);
|
|
1508
|
-
}
|
|
1509
|
-
async function assertExistingDestinationIsSafe(filename) {
|
|
1510
|
-
try {
|
|
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;
|
|
1517
|
-
}
|
|
1518
|
-
}
|
|
1519
|
-
async function assertPrivateRegularFile(filename) {
|
|
1520
|
-
const stat = await lstat(filename);
|
|
1521
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
1522
|
-
throw new Error("Tonbo credential path must be a regular file.");
|
|
1523
|
-
if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
|
|
1524
|
-
throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
|
|
1525
|
-
}
|
|
1526
1980
|
|
|
1527
1981
|
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1982
|
+
import { createHash as createHash3, randomBytes } from "node:crypto";
|
|
1983
|
+
import { createServer } from "node:http";
|
|
1984
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1985
|
+
import os4 from "node:os";
|
|
1986
|
+
import { promisify as promisify2 } from "node:util";
|
|
1987
|
+
|
|
1988
|
+
// ../../packages/tonbo-cli-core/dist/src/callback-page.js
|
|
1989
|
+
var WORDMARK = [
|
|
1990
|
+
"\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",
|
|
1991
|
+
"\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
|
|
1992
|
+
"\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"
|
|
1993
|
+
].join("\n");
|
|
1994
|
+
var OUTCOMES = {
|
|
1995
|
+
complete: {
|
|
1996
|
+
status: 200,
|
|
1997
|
+
title: "You are signed in.",
|
|
1998
|
+
body: "Return to your terminal. This tab can be closed."
|
|
1999
|
+
},
|
|
2000
|
+
denied: {
|
|
2001
|
+
status: 400,
|
|
2002
|
+
title: "Login was not completed.",
|
|
2003
|
+
body: "Nothing was authorized. Return to your terminal to try again."
|
|
2004
|
+
},
|
|
2005
|
+
invalid: {
|
|
2006
|
+
// The state did not match, the code was missing, or the path was wrong.
|
|
2007
|
+
// Say that it could not be verified rather than which check failed: the
|
|
2008
|
+
// person who can read this page is not necessarily the one who started
|
|
2009
|
+
// the login.
|
|
2010
|
+
status: 400,
|
|
2011
|
+
title: "This callback could not be verified.",
|
|
2012
|
+
body: "Return to your terminal and start the login again."
|
|
2013
|
+
}
|
|
1532
2014
|
};
|
|
1533
|
-
function
|
|
1534
|
-
const
|
|
1535
|
-
return
|
|
2015
|
+
function renderCallbackPage(outcome) {
|
|
2016
|
+
const { title, body } = OUTCOMES[outcome];
|
|
2017
|
+
return `<!doctype html>
|
|
2018
|
+
<html lang="en">
|
|
2019
|
+
<head>
|
|
2020
|
+
<meta charset="utf-8">
|
|
2021
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
2022
|
+
<meta name="robots" content="noindex">
|
|
2023
|
+
<title>Tonbo CLI</title>
|
|
2024
|
+
<style>
|
|
2025
|
+
:root {
|
|
2026
|
+
--paper: #f3f2ee;
|
|
2027
|
+
--ink: #1d1b17;
|
|
2028
|
+
--rule: #c9c5ba;
|
|
2029
|
+
--quiet: #5c564a;
|
|
2030
|
+
--signal: #d64a03;
|
|
2031
|
+
}
|
|
2032
|
+
@media (prefers-color-scheme: dark) {
|
|
2033
|
+
:root {
|
|
2034
|
+
--paper: #1d1b17;
|
|
2035
|
+
--ink: #f3f2ee;
|
|
2036
|
+
--rule: #3a362e;
|
|
2037
|
+
--quiet: #a49e8f;
|
|
2038
|
+
--signal: #f08b4b;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
* { box-sizing: border-box; }
|
|
2042
|
+
body {
|
|
2043
|
+
margin: 0;
|
|
2044
|
+
min-height: 100vh;
|
|
2045
|
+
display: grid;
|
|
2046
|
+
grid-template-rows: auto 1fr;
|
|
2047
|
+
padding: 0 clamp(20px, 5vw, 72px);
|
|
2048
|
+
background: var(--paper);
|
|
2049
|
+
color: var(--ink);
|
|
2050
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
2051
|
+
-webkit-font-smoothing: antialiased;
|
|
2052
|
+
}
|
|
2053
|
+
header { padding: 28px 0; }
|
|
2054
|
+
/* Same metrics as .auth-wordmark in the app: the glyphs are the ground
|
|
2055
|
+
and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
|
|
2056
|
+
what makes the rows meet without seams. Iosevka is not available here,
|
|
2057
|
+
so it falls back to the platform monospace. */
|
|
2058
|
+
pre.wordmark {
|
|
2059
|
+
margin: 0;
|
|
2060
|
+
font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
|
|
2061
|
+
font-size: 8px;
|
|
2062
|
+
font-weight: 400;
|
|
2063
|
+
line-height: 10px;
|
|
2064
|
+
letter-spacing: 0;
|
|
2065
|
+
white-space: pre;
|
|
2066
|
+
color: var(--signal);
|
|
2067
|
+
transform: scaleY(0.8);
|
|
2068
|
+
transform-origin: left top;
|
|
2069
|
+
}
|
|
2070
|
+
main {
|
|
2071
|
+
display: flex;
|
|
2072
|
+
flex-direction: column;
|
|
2073
|
+
justify-content: center;
|
|
2074
|
+
max-width: 46ch;
|
|
2075
|
+
padding-bottom: 12vh;
|
|
2076
|
+
}
|
|
2077
|
+
h1 {
|
|
2078
|
+
margin: 0;
|
|
2079
|
+
font-size: 26px;
|
|
2080
|
+
font-weight: 300;
|
|
2081
|
+
line-height: 1.16;
|
|
2082
|
+
letter-spacing: -0.018em;
|
|
2083
|
+
}
|
|
2084
|
+
p {
|
|
2085
|
+
margin: 14px 0 0;
|
|
2086
|
+
font-size: 16px;
|
|
2087
|
+
line-height: 1.55;
|
|
2088
|
+
color: var(--quiet);
|
|
2089
|
+
}
|
|
2090
|
+
hr {
|
|
2091
|
+
width: 40px;
|
|
2092
|
+
margin: 28px 0 0;
|
|
2093
|
+
border: 0;
|
|
2094
|
+
border-top: 1px solid var(--rule);
|
|
2095
|
+
}
|
|
2096
|
+
</style>
|
|
2097
|
+
</head>
|
|
2098
|
+
<body>
|
|
2099
|
+
<header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
|
|
2100
|
+
<main>
|
|
2101
|
+
<h1>${title}</h1>
|
|
2102
|
+
<p>${body}</p>
|
|
2103
|
+
<hr>
|
|
2104
|
+
</main>
|
|
2105
|
+
</body>
|
|
2106
|
+
</html>
|
|
2107
|
+
`;
|
|
2108
|
+
}
|
|
2109
|
+
function callbackResponse(outcome) {
|
|
2110
|
+
return {
|
|
2111
|
+
status: OUTCOMES[outcome].status,
|
|
2112
|
+
headers: {
|
|
2113
|
+
"content-type": "text/html; charset=utf-8",
|
|
2114
|
+
"cache-control": "no-store"
|
|
2115
|
+
},
|
|
2116
|
+
body: renderCallbackPage(outcome)
|
|
2117
|
+
};
|
|
1536
2118
|
}
|
|
1537
|
-
|
|
1538
|
-
|
|
2119
|
+
|
|
2120
|
+
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
2121
|
+
import { randomUUID } from "node:crypto";
|
|
2122
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
2123
|
+
import os from "node:os";
|
|
2124
|
+
import path from "node:path";
|
|
2125
|
+
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
2126
|
+
var FILE_MODE = Number.parseInt(credentialsContract.permissions.file_mode, 8);
|
|
2127
|
+
var REFUSED_MODE_BITS = Number.parseInt(credentialsContract.reader.refuse_mode_bits, 8);
|
|
2128
|
+
function defaultCredentialsPath(env = process.env) {
|
|
2129
|
+
const rule = credentialsContract.path;
|
|
2130
|
+
const base = env[rule.base_env] || path.join(...rule.fallback_base.map((part) => part === "$HOME" ? os.homedir() : part));
|
|
2131
|
+
return path.join(base, ...rule.relative);
|
|
1539
2132
|
}
|
|
1540
|
-
function
|
|
1541
|
-
|
|
1542
|
-
return cleaned || "computer";
|
|
2133
|
+
function isExpiredLogin(login, now = Date.now()) {
|
|
2134
|
+
return login.expires_at !== null && Date.parse(login.expires_at) <= now;
|
|
1543
2135
|
}
|
|
1544
|
-
var
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
browserOpener;
|
|
1549
|
-
hostname;
|
|
2136
|
+
var UnusableLoginError = class extends Error {
|
|
2137
|
+
};
|
|
2138
|
+
var FileCredentialStore = class {
|
|
2139
|
+
filename;
|
|
1550
2140
|
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;
|
|
2141
|
+
constructor(filename = defaultCredentialsPath(), loginHint = credentialsContract.reader.login_hint) {
|
|
2142
|
+
this.filename = filename;
|
|
1559
2143
|
this.loginHint = loginHint;
|
|
1560
|
-
this.entryLine = entryLine;
|
|
1561
|
-
this.env = env;
|
|
1562
2144
|
}
|
|
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;
|
|
2145
|
+
async load() {
|
|
2146
|
+
const parsed = await readPrivateJson(this.filename);
|
|
2147
|
+
if (parsed === null)
|
|
2148
|
+
return null;
|
|
2149
|
+
if (!isStoredLogin(parsed))
|
|
2150
|
+
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
2151
|
+
return parsed;
|
|
1575
2152
|
}
|
|
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;
|
|
2153
|
+
/** Writes a legacy login; nothing but tests writes one from ADR-0018. */
|
|
2154
|
+
async save(login) {
|
|
2155
|
+
if (!isStoredLogin(login))
|
|
2156
|
+
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
2157
|
+
await writePrivateJson(this.filename, login);
|
|
1621
2158
|
}
|
|
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;
|
|
2159
|
+
async remove() {
|
|
2160
|
+
await removePrivateFile(this.filename);
|
|
1646
2161
|
}
|
|
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
|
-
}
|
|
2162
|
+
};
|
|
2163
|
+
async function readPrivateJson(filename) {
|
|
2164
|
+
try {
|
|
2165
|
+
await assertPrivateRegularFile(filename);
|
|
2166
|
+
return JSON.parse(await readFile(filename, "utf8"));
|
|
2167
|
+
} catch (error) {
|
|
2168
|
+
if (error.code === "ENOENT")
|
|
2169
|
+
return null;
|
|
2170
|
+
throw new Error(`Could not read Tonbo credentials at ${filename}.`, { cause: error });
|
|
1713
2171
|
}
|
|
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
|
-
});
|
|
2172
|
+
}
|
|
2173
|
+
async function writePrivateJson(filename, value) {
|
|
2174
|
+
const directory = path.dirname(filename);
|
|
2175
|
+
await mkdir(directory, { mode: DIRECTORY_MODE, recursive: true });
|
|
2176
|
+
await preparePrivateDirectory(directory);
|
|
2177
|
+
await assertExistingDestinationIsSafe(filename);
|
|
2178
|
+
const temporary = path.join(directory, `.${path.basename(filename)}.${process.pid}.${randomUUID()}.tmp`);
|
|
2179
|
+
let handle = null;
|
|
2180
|
+
try {
|
|
2181
|
+
handle = await open(temporary, "wx", FILE_MODE);
|
|
2182
|
+
await handle.writeFile(`${JSON.stringify(value, null, 2)}
|
|
2183
|
+
`, "utf8");
|
|
2184
|
+
await handle.sync();
|
|
2185
|
+
await handle.close();
|
|
2186
|
+
handle = null;
|
|
2187
|
+
if (process.platform !== "win32")
|
|
2188
|
+
await chmod(temporary, FILE_MODE);
|
|
2189
|
+
await rename(temporary, filename);
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
await handle?.close().catch(() => void 0);
|
|
2192
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
2193
|
+
throw new Error(`Could not store Tonbo credentials at ${filename}.`, { cause: error });
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
async function removePrivateFile(filename) {
|
|
2197
|
+
await assertExistingDestinationIsSafe(filename);
|
|
2198
|
+
await rm(filename, { force: true });
|
|
2199
|
+
}
|
|
2200
|
+
var KEYWORDS = /* @__PURE__ */ new Set([
|
|
2201
|
+
"$schema",
|
|
2202
|
+
"$id",
|
|
2203
|
+
"title",
|
|
2204
|
+
"description",
|
|
2205
|
+
"const",
|
|
2206
|
+
"type",
|
|
2207
|
+
"minLength",
|
|
2208
|
+
"pattern",
|
|
2209
|
+
"format",
|
|
2210
|
+
"required",
|
|
2211
|
+
"properties",
|
|
2212
|
+
"additionalProperties"
|
|
2213
|
+
]);
|
|
2214
|
+
function isPlainObject(value) {
|
|
2215
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
2216
|
+
}
|
|
2217
|
+
function matchesSchema(value, schema) {
|
|
2218
|
+
const rule = schema;
|
|
2219
|
+
if (Object.keys(rule).some((keyword) => !KEYWORDS.has(keyword)))
|
|
2220
|
+
return false;
|
|
2221
|
+
if ("const" in rule)
|
|
2222
|
+
return value === rule.const;
|
|
2223
|
+
const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
|
|
2224
|
+
if (value === null)
|
|
2225
|
+
return types.includes("null");
|
|
2226
|
+
if (isPlainObject(value)) {
|
|
2227
|
+
if (!types.includes("object"))
|
|
2228
|
+
return false;
|
|
2229
|
+
if (!(rule.required ?? []).every((name) => Object.hasOwn(value, name)))
|
|
2230
|
+
return false;
|
|
2231
|
+
const properties = rule.properties ?? {};
|
|
2232
|
+
for (const [name, inner] of Object.entries(value)) {
|
|
2233
|
+
if (Object.hasOwn(properties, name)) {
|
|
2234
|
+
if (!matchesSchema(inner, properties[name]))
|
|
2235
|
+
return false;
|
|
2236
|
+
} else if (isPlainObject(rule.additionalProperties)) {
|
|
2237
|
+
if (!name || !matchesSchema(inner, rule.additionalProperties))
|
|
2238
|
+
return false;
|
|
2239
|
+
} else if (rule.additionalProperties === false)
|
|
2240
|
+
return false;
|
|
2241
|
+
}
|
|
2242
|
+
return true;
|
|
1799
2243
|
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
2244
|
+
if (typeof value !== "string" || !types.includes("string"))
|
|
2245
|
+
return false;
|
|
2246
|
+
if (rule.minLength !== void 0 && value.length < rule.minLength)
|
|
2247
|
+
return false;
|
|
2248
|
+
if (rule.pattern !== void 0 && !new RegExp(rule.pattern, "u").test(value))
|
|
2249
|
+
return false;
|
|
2250
|
+
if (rule.format === "date-time" && Number.isNaN(Date.parse(value)))
|
|
2251
|
+
return false;
|
|
2252
|
+
return true;
|
|
1803
2253
|
}
|
|
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);
|
|
2254
|
+
function isStoredLogin(value) {
|
|
2255
|
+
return isPlainObject(value) && value.version === credentialsContract.current_version && matchesSchema(value, credentialsContract.schema);
|
|
1810
2256
|
}
|
|
1811
|
-
function
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2257
|
+
function accountLogins(login) {
|
|
2258
|
+
const entries = Object.entries(login.accounts ?? {}).map(([account_id, entry]) => ({
|
|
2259
|
+
...entry,
|
|
2260
|
+
account_id,
|
|
2261
|
+
kind: entry.kind ?? null,
|
|
2262
|
+
name: entry.name ?? null
|
|
2263
|
+
}));
|
|
2264
|
+
if (!entries.some((entry) => entry.account_id === login.account_id))
|
|
2265
|
+
entries.unshift({
|
|
2266
|
+
account_id: login.account_id,
|
|
2267
|
+
key_id: login.key_id,
|
|
2268
|
+
key: login.key,
|
|
2269
|
+
expires_at: login.expires_at,
|
|
2270
|
+
kind: null,
|
|
2271
|
+
name: null
|
|
2272
|
+
});
|
|
2273
|
+
return entries;
|
|
1819
2274
|
}
|
|
1820
|
-
async function
|
|
1821
|
-
const
|
|
2275
|
+
async function preparePrivateDirectory(directory) {
|
|
2276
|
+
const stat = await lstat(directory);
|
|
2277
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
2278
|
+
throw new Error("Tonbo config directory must be a real directory.");
|
|
2279
|
+
if (process.platform !== "win32")
|
|
2280
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
2281
|
+
}
|
|
2282
|
+
async function assertExistingDestinationIsSafe(filename) {
|
|
1822
2283
|
try {
|
|
1823
|
-
await
|
|
2284
|
+
const stat = await lstat(filename);
|
|
2285
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
2286
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
1824
2287
|
} 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;
|
|
2288
|
+
if (error.code !== "ENOENT")
|
|
2289
|
+
throw error;
|
|
1831
2290
|
}
|
|
1832
2291
|
}
|
|
2292
|
+
async function assertPrivateRegularFile(filename) {
|
|
2293
|
+
const stat = await lstat(filename);
|
|
2294
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
2295
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
2296
|
+
if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
|
|
2297
|
+
throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
|
|
2298
|
+
}
|
|
1833
2299
|
|
|
1834
|
-
// ../../packages/tonbo-cli-core/dist/src/
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
2300
|
+
// ../../packages/tonbo-cli-core/dist/src/device.js
|
|
2301
|
+
import { execFile } from "node:child_process";
|
|
2302
|
+
import { createHash, randomUUID as randomUUID2 } from "node:crypto";
|
|
2303
|
+
import { link, mkdir as mkdir2, open as open2, readFile as readFile2, rename as rename2, rm as rm2 } from "node:fs/promises";
|
|
2304
|
+
import os2 from "node:os";
|
|
2305
|
+
import path2 from "node:path";
|
|
2306
|
+
import { promisify } from "node:util";
|
|
2307
|
+
function defaultDevicePath(env = process.env, home = os2.homedir()) {
|
|
2308
|
+
const rule = loginContract.device_file;
|
|
2309
|
+
const base = env[rule.base_env] || path2.join(...rule.fallback_base.map((part) => part === "$HOME" ? home : part));
|
|
2310
|
+
return path2.join(base, ...rule.relative);
|
|
2311
|
+
}
|
|
2312
|
+
var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2313
|
+
var SHA256 = /^[0-9a-f]{64}$/;
|
|
2314
|
+
var execFileAsync = promisify(execFile);
|
|
2315
|
+
async function readMachineId(platform = process.platform) {
|
|
2316
|
+
try {
|
|
2317
|
+
if (platform === "darwin") {
|
|
2318
|
+
const { stdout } = await execFileAsync("/usr/sbin/ioreg", ["-rd1", "-c", "IOPlatformExpertDevice"], { timeout: 5e3 });
|
|
2319
|
+
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(stdout)?.[1].trim() || null;
|
|
2320
|
+
}
|
|
2321
|
+
if (platform === "linux")
|
|
2322
|
+
return (await readFile2("/etc/machine-id", "utf8")).trim() || null;
|
|
2323
|
+
} catch {
|
|
1850
2324
|
}
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
2325
|
+
return null;
|
|
2326
|
+
}
|
|
2327
|
+
function machineIdHash(machineId) {
|
|
2328
|
+
return createHash("sha256").update(machineId).digest("hex");
|
|
2329
|
+
}
|
|
2330
|
+
var DeviceStore = class {
|
|
2331
|
+
filename;
|
|
2332
|
+
machineId;
|
|
2333
|
+
constructor(filename = defaultDevicePath(), machineId = readMachineId) {
|
|
2334
|
+
this.filename = filename;
|
|
2335
|
+
this.machineId = machineId;
|
|
1855
2336
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
const
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
2337
|
+
/**
|
|
2338
|
+
* This computer's device id. The first call makes it, a random UUID saved
|
|
2339
|
+
* with the hash of the machine id; two first runs at once agree on the one
|
|
2340
|
+
* saved first. When the saved hash and the machine's differ, the state
|
|
2341
|
+
* directory came from another computer (a cloned disk), and a new id
|
|
2342
|
+
* replaces it. A machine id that cannot be read keeps the saved id.
|
|
2343
|
+
*/
|
|
2344
|
+
async deviceId() {
|
|
2345
|
+
const machineId = await this.machineId().catch(() => null);
|
|
2346
|
+
const hash = machineId ? machineIdHash(machineId) : null;
|
|
2347
|
+
const saved = await this.load();
|
|
2348
|
+
if (saved && (saved.machine_id_sha256 === hash || hash === null))
|
|
2349
|
+
return saved.device_id;
|
|
2350
|
+
if (saved && saved.machine_id_sha256 === null) {
|
|
2351
|
+
await this.write({ ...saved, machine_id_sha256: hash }, true);
|
|
2352
|
+
return saved.device_id;
|
|
1871
2353
|
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
if (
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
2354
|
+
const record = { version: 1, device_id: randomUUID2(), machine_id_sha256: hash };
|
|
2355
|
+
if (saved) {
|
|
2356
|
+
await this.write(record, true);
|
|
2357
|
+
return record.device_id;
|
|
2358
|
+
}
|
|
2359
|
+
if (await this.write(record, false))
|
|
2360
|
+
return record.device_id;
|
|
2361
|
+
const winner = await this.load();
|
|
2362
|
+
if (!winner)
|
|
2363
|
+
throw new Error(`${this.filename} vanished while it was made.`);
|
|
2364
|
+
return winner.device_id;
|
|
2365
|
+
}
|
|
2366
|
+
/** The saved record, or null before the first run. */
|
|
2367
|
+
async load() {
|
|
2368
|
+
let body;
|
|
1883
2369
|
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
|
-
});
|
|
2370
|
+
body = await readFile2(this.filename, "utf8");
|
|
1894
2371
|
} catch (error) {
|
|
1895
|
-
if (error
|
|
1896
|
-
|
|
2372
|
+
if (error.code === "ENOENT")
|
|
2373
|
+
return null;
|
|
1897
2374
|
throw error;
|
|
1898
2375
|
}
|
|
2376
|
+
let parsed = null;
|
|
2377
|
+
try {
|
|
2378
|
+
parsed = JSON.parse(body);
|
|
2379
|
+
} catch {
|
|
2380
|
+
}
|
|
2381
|
+
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))))
|
|
2382
|
+
throw new Error(`${this.filename} is not a Tonbo device file.`);
|
|
2383
|
+
return parsed;
|
|
1899
2384
|
}
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
2385
|
+
/** Writes `record` through a synced temporary file: in place of the saved
|
|
2386
|
+
* one when `replace`, otherwise only if there is none (false when there
|
|
2387
|
+
* was). */
|
|
2388
|
+
async write(record, replace) {
|
|
2389
|
+
const directory = path2.dirname(this.filename);
|
|
2390
|
+
await mkdir2(directory, { recursive: true, mode: 448 });
|
|
2391
|
+
const temporary = path2.join(directory, `.device.${process.pid}.${randomUUID2()}.tmp`);
|
|
2392
|
+
const handle = await open2(temporary, "wx", 384);
|
|
2393
|
+
try {
|
|
2394
|
+
await handle.writeFile(JSON.stringify(record));
|
|
2395
|
+
await handle.sync();
|
|
2396
|
+
} finally {
|
|
2397
|
+
await handle.close();
|
|
2398
|
+
}
|
|
2399
|
+
try {
|
|
2400
|
+
if (replace) {
|
|
2401
|
+
await rename2(temporary, this.filename);
|
|
2402
|
+
return true;
|
|
2403
|
+
}
|
|
2404
|
+
await link(temporary, this.filename);
|
|
2405
|
+
return true;
|
|
2406
|
+
} catch (error) {
|
|
2407
|
+
if (!replace && error.code === "EEXIST")
|
|
2408
|
+
return false;
|
|
2409
|
+
throw error;
|
|
2410
|
+
} finally {
|
|
2411
|
+
await rm2(temporary, { force: true });
|
|
2412
|
+
}
|
|
1911
2413
|
}
|
|
1912
|
-
|
|
1913
|
-
|
|
2414
|
+
};
|
|
2415
|
+
|
|
2416
|
+
// ../../packages/tonbo-cli-core/dist/src/session.js
|
|
2417
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2418
|
+
import os3 from "node:os";
|
|
2419
|
+
import path3 from "node:path";
|
|
2420
|
+
function defaultLoginPath(env = process.env) {
|
|
2421
|
+
const rule = loginContract.path;
|
|
2422
|
+
const base = env[rule.base_env] || path3.join(...rule.fallback_base.map((part) => part === "$HOME" ? os3.homedir() : part));
|
|
2423
|
+
return path3.join(base, ...rule.relative);
|
|
2424
|
+
}
|
|
2425
|
+
function isStoredSession(value) {
|
|
2426
|
+
return !!value && typeof value === "object" && value.version === loginContract.current_version && matchesSchema(value, loginContract.schema);
|
|
2427
|
+
}
|
|
2428
|
+
function deviceIdHash(deviceId) {
|
|
2429
|
+
return createHash2("sha256").update(deviceId, "utf8").digest("hex");
|
|
2430
|
+
}
|
|
2431
|
+
function isExpiredSession(stored, now = Date.now()) {
|
|
2432
|
+
return Date.parse(stored.session.expires_at) <= now;
|
|
2433
|
+
}
|
|
2434
|
+
var FileSessionStore = class {
|
|
2435
|
+
filename;
|
|
2436
|
+
loginHint;
|
|
2437
|
+
constructor(filename = defaultLoginPath(), loginHint = loginContract.reader.login_hint) {
|
|
2438
|
+
this.filename = filename;
|
|
2439
|
+
this.loginHint = loginHint;
|
|
1914
2440
|
}
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
2441
|
+
async load() {
|
|
2442
|
+
const parsed = await readPrivateJson(this.filename);
|
|
2443
|
+
if (parsed === null)
|
|
2444
|
+
return null;
|
|
2445
|
+
if (!isStoredSession(parsed))
|
|
2446
|
+
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
2447
|
+
return parsed;
|
|
1922
2448
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
2449
|
+
async save(stored) {
|
|
2450
|
+
if (!isStoredSession(stored))
|
|
2451
|
+
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
2452
|
+
await writePrivateJson(this.filename, stored);
|
|
1925
2453
|
}
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
createApiKey(key, request) {
|
|
1929
|
-
return this.call("createApiKey", key, { body: withoutUndefined(request) });
|
|
2454
|
+
async remove() {
|
|
2455
|
+
await removePrivateFile(this.filename);
|
|
1930
2456
|
}
|
|
1931
|
-
|
|
1932
|
-
|
|
2457
|
+
};
|
|
2458
|
+
var REFRESH_MARGIN_MS = 6e4;
|
|
2459
|
+
var clients = /* @__PURE__ */ new WeakMap();
|
|
2460
|
+
var SessionClient = class _SessionClient {
|
|
2461
|
+
stored;
|
|
2462
|
+
origins;
|
|
2463
|
+
deviceId;
|
|
2464
|
+
bearers = /* @__PURE__ */ new Map();
|
|
2465
|
+
tokens = /* @__PURE__ */ new Map();
|
|
2466
|
+
inflight = /* @__PURE__ */ new Map();
|
|
2467
|
+
workspaces = /* @__PURE__ */ new Map();
|
|
2468
|
+
constructor(stored, origins, deviceId) {
|
|
2469
|
+
this.stored = stored;
|
|
2470
|
+
this.origins = origins;
|
|
2471
|
+
this.deviceId = deviceId;
|
|
1933
2472
|
}
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2473
|
+
/** The client of `stored`, shared by every read of the same login. */
|
|
2474
|
+
static for(stored, origins, deviceId) {
|
|
2475
|
+
const id = `${origins.loginOrigin} ${origins.apiOrigin} ${stored.session.secret}`;
|
|
2476
|
+
let known = clients.get(origins.fetcher);
|
|
2477
|
+
if (!known)
|
|
2478
|
+
clients.set(origins.fetcher, known = /* @__PURE__ */ new Map());
|
|
2479
|
+
const client = known.get(id);
|
|
2480
|
+
if (client) {
|
|
2481
|
+
client.stored = stored;
|
|
2482
|
+
client.deviceId = deviceId;
|
|
2483
|
+
return client;
|
|
2484
|
+
}
|
|
2485
|
+
const made = new _SessionClient(stored, origins, deviceId);
|
|
2486
|
+
known.set(id, made);
|
|
2487
|
+
return made;
|
|
1938
2488
|
}
|
|
1939
|
-
|
|
1940
|
-
return this.
|
|
2489
|
+
now() {
|
|
2490
|
+
return (this.origins.now ?? Date.now)();
|
|
1941
2491
|
}
|
|
1942
|
-
/**
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2492
|
+
/** The bearer of `accountId`: tokens of that account, exchanged from this session. */
|
|
2493
|
+
bearer(accountId) {
|
|
2494
|
+
let bearer = this.bearers.get(accountId);
|
|
2495
|
+
if (!bearer) {
|
|
2496
|
+
bearer = {
|
|
2497
|
+
kind: "session",
|
|
2498
|
+
accountId,
|
|
2499
|
+
token: (audience) => this.token(audience, accountId),
|
|
2500
|
+
invalidate: (audience) => {
|
|
2501
|
+
this.tokens.delete(`${audience} ${accountId}`);
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
this.bearers.set(accountId, bearer);
|
|
2505
|
+
}
|
|
2506
|
+
return bearer;
|
|
1949
2507
|
}
|
|
1950
|
-
|
|
1951
|
-
|
|
2508
|
+
/** A token of `accountId` for `audience`, kept until a minute before it expires. */
|
|
2509
|
+
async token(audience, accountId) {
|
|
2510
|
+
const id = `${audience} ${accountId}`;
|
|
2511
|
+
const cached = this.tokens.get(id);
|
|
2512
|
+
if (cached && cached.refreshAt > this.now())
|
|
2513
|
+
return cached.token;
|
|
2514
|
+
let pending = this.inflight.get(id);
|
|
2515
|
+
if (!pending) {
|
|
2516
|
+
pending = this.exchange(audience, accountId).finally(() => this.inflight.delete(id));
|
|
2517
|
+
this.inflight.set(id, pending);
|
|
2518
|
+
}
|
|
2519
|
+
return pending;
|
|
1952
2520
|
}
|
|
1953
|
-
|
|
1954
|
-
|
|
2521
|
+
/** The device id every exchange and the session endpoint name. */
|
|
2522
|
+
presentedDevice() {
|
|
2523
|
+
if (this.deviceId === null)
|
|
2524
|
+
throw new AccessTokenError(`This login was made on another computer. Run \`${this.origins.loginHint}\` here.`, 401, null, "cli_session_device_mismatch");
|
|
2525
|
+
return this.deviceId;
|
|
1955
2526
|
}
|
|
1956
|
-
|
|
1957
|
-
return this.
|
|
2527
|
+
accountName(accountId) {
|
|
2528
|
+
return this.stored.accounts?.[accountId]?.name ?? accountId;
|
|
1958
2529
|
}
|
|
1959
|
-
|
|
1960
|
-
|
|
2530
|
+
/** POST /api/iam/token with the session (access-token-v1.json `cli_session.exchange`). */
|
|
2531
|
+
async exchange(audience, accountId) {
|
|
2532
|
+
const what = audience === GIT_AUDIENCE ? "git" : audience === MANAGEMENT_AUDIENCE ? "Management" : "Streams";
|
|
2533
|
+
if (isExpiredSession(this.stored, this.now()))
|
|
2534
|
+
throw new AccessTokenError(`Your Tonbo login has expired. Run \`${this.origins.loginHint}\`.`, 401, null, "cli_session_invalid");
|
|
2535
|
+
const workspace = audience === GIT_AUDIENCE && accountId !== this.stored.personal_account_id ? await this.defaultWorkspace(accountId) : void 0;
|
|
2536
|
+
const issuedAt = this.now();
|
|
2537
|
+
const body = await this.post({
|
|
2538
|
+
audience,
|
|
2539
|
+
device: this.presentedDevice(),
|
|
2540
|
+
account: accountId,
|
|
2541
|
+
...workspace ? { workspace } : {}
|
|
2542
|
+
}, what, accountId);
|
|
2543
|
+
this.tokens.set(`${audience} ${accountId}`, {
|
|
2544
|
+
token: body.access_token,
|
|
2545
|
+
refreshAt: issuedAt + Math.max(0, body.expires_in * 1e3 - REFRESH_MARGIN_MS)
|
|
2546
|
+
});
|
|
2547
|
+
return body.access_token;
|
|
1961
2548
|
}
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2549
|
+
async post(fields2, what, accountId) {
|
|
2550
|
+
let response;
|
|
2551
|
+
try {
|
|
2552
|
+
response = await this.origins.fetcher(`${this.origins.loginOrigin}${cliSessionContract.exchangePath}`, {
|
|
2553
|
+
method: "POST",
|
|
2554
|
+
redirect: "manual",
|
|
2555
|
+
signal: AbortSignal.timeout(3e4),
|
|
2556
|
+
headers: {
|
|
2557
|
+
authorization: `Bearer ${this.stored.session.secret}`,
|
|
2558
|
+
"content-type": "application/json"
|
|
2559
|
+
},
|
|
2560
|
+
body: JSON.stringify(fields2)
|
|
2561
|
+
});
|
|
2562
|
+
} catch (error) {
|
|
2563
|
+
throw new AccessTokenError(`The ${what} token exchange failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2564
|
+
}
|
|
2565
|
+
if (!response.ok)
|
|
2566
|
+
throw await this.refusal(response, what, accountId);
|
|
2567
|
+
const body = await response.json().catch(() => null);
|
|
2568
|
+
if (typeof body?.access_token !== "string" || !body.access_token || typeof body.expires_in !== "number" || !(body.expires_in > 0))
|
|
2569
|
+
throw new AccessTokenError(`The ${what} token exchange answered no token.`, response.status);
|
|
2570
|
+
return { access_token: body.access_token, expires_in: body.expires_in };
|
|
1969
2571
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
2572
|
+
/** The exchange's refusal (`cli_session.exchange.refusals`) as an AccessTokenError. */
|
|
2573
|
+
async refusal(response, what, accountId) {
|
|
2574
|
+
const problem = await response.json().catch(() => null);
|
|
2575
|
+
const code = typeof problem?.code === "string" ? problem.code : null;
|
|
2576
|
+
const retryAfter = Number(response.headers.get("retry-after") ?? "");
|
|
2577
|
+
const hint = this.origins.loginHint;
|
|
2578
|
+
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}.`;
|
|
2579
|
+
return new AccessTokenError(message, response.status, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : null, code);
|
|
1972
2580
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
2581
|
+
/** An organization's default git workspace, read with its Management token. */
|
|
2582
|
+
defaultWorkspace(accountId) {
|
|
2583
|
+
let pending = this.workspaces.get(accountId);
|
|
2584
|
+
if (!pending) {
|
|
2585
|
+
pending = this.readDefaultWorkspace(accountId).catch((error) => {
|
|
2586
|
+
this.workspaces.delete(accountId);
|
|
2587
|
+
throw error;
|
|
2588
|
+
});
|
|
2589
|
+
this.workspaces.set(accountId, pending);
|
|
2590
|
+
}
|
|
2591
|
+
return pending;
|
|
1975
2592
|
}
|
|
1976
|
-
|
|
1977
|
-
|
|
2593
|
+
async readDefaultWorkspace(accountId) {
|
|
2594
|
+
const token = await this.token(MANAGEMENT_AUDIENCE, accountId);
|
|
2595
|
+
const operation = managementOperations.listGitWorkspaces;
|
|
2596
|
+
let response;
|
|
2597
|
+
try {
|
|
2598
|
+
response = await this.origins.fetcher(`${this.origins.apiOrigin}${managementApiBasePath}${operation.path}?limit=100`, {
|
|
2599
|
+
redirect: "manual",
|
|
2600
|
+
signal: AbortSignal.timeout(3e4),
|
|
2601
|
+
headers: { authorization: `Bearer ${token}`, accept: "application/json" }
|
|
2602
|
+
});
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2605
|
+
}
|
|
2606
|
+
const body = await response.json().catch(() => null);
|
|
2607
|
+
if (!response.ok)
|
|
2608
|
+
throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces answered ${response.status}.`, response.status);
|
|
2609
|
+
const found = body?.data?.find((workspace) => workspace.default === true);
|
|
2610
|
+
if (typeof found?.id !== "string")
|
|
2611
|
+
throw new AccessTokenError(`${this.accountName(accountId)} has no default git workspace.`, 404, null, "git_workspace_not_found");
|
|
2612
|
+
return found.id;
|
|
1978
2613
|
}
|
|
1979
|
-
/**
|
|
1980
|
-
|
|
1981
|
-
|
|
2614
|
+
/** GET /api/iam/session: the person's accounts now, the personal account first. */
|
|
2615
|
+
async accounts() {
|
|
2616
|
+
const url = new URL(cliSessionContract.sessionPath, this.origins.loginOrigin);
|
|
2617
|
+
url.searchParams.set("device", this.presentedDevice());
|
|
2618
|
+
let response;
|
|
2619
|
+
try {
|
|
2620
|
+
response = await this.origins.fetcher(url.toString(), {
|
|
2621
|
+
redirect: "manual",
|
|
2622
|
+
signal: AbortSignal.timeout(3e4),
|
|
2623
|
+
headers: {
|
|
2624
|
+
authorization: `Bearer ${this.stored.session.secret}`,
|
|
2625
|
+
accept: "application/json"
|
|
2626
|
+
}
|
|
2627
|
+
});
|
|
2628
|
+
} catch (error) {
|
|
2629
|
+
throw new AccessTokenError(`Reading your accounts failed: ${error instanceof Error ? error.message : String(error)}`, null);
|
|
2630
|
+
}
|
|
2631
|
+
if (!response.ok)
|
|
2632
|
+
throw await this.refusal(response, "session", "");
|
|
2633
|
+
const body = await response.json().catch(() => null);
|
|
2634
|
+
if (!Array.isArray(body?.accounts))
|
|
2635
|
+
throw new AccessTokenError("The session endpoint answered no accounts.", response.status);
|
|
2636
|
+
return body.accounts.filter((account) => typeof account?.id === "string" && typeof account.name === "string" && (account.kind === "user" || account.kind === "organization"));
|
|
1982
2637
|
}
|
|
1983
|
-
/**
|
|
1984
|
-
|
|
1985
|
-
|
|
2638
|
+
/** DELETE /api/iam/session: ends the session, from any computer. */
|
|
2639
|
+
async logout() {
|
|
2640
|
+
const response = await this.origins.fetcher(new URL(cliSessionContract.sessionPath, this.origins.loginOrigin).toString(), {
|
|
2641
|
+
method: "DELETE",
|
|
2642
|
+
redirect: "manual",
|
|
2643
|
+
signal: AbortSignal.timeout(3e4),
|
|
2644
|
+
headers: { authorization: `Bearer ${this.stored.session.secret}` }
|
|
2645
|
+
});
|
|
2646
|
+
await response.body?.cancel();
|
|
2647
|
+
if (!response.ok && response.status !== 401)
|
|
2648
|
+
throw new Error(`The logout answered ${response.status}.`);
|
|
1986
2649
|
}
|
|
1987
|
-
|
|
1988
|
-
|
|
2650
|
+
};
|
|
2651
|
+
function withAccounts(stored, accounts) {
|
|
2652
|
+
const cache = {};
|
|
2653
|
+
for (const account of accounts)
|
|
2654
|
+
cache[account.id] = { name: account.name, kind: account.kind, role: account.role };
|
|
2655
|
+
return { ...stored, accounts: cache };
|
|
2656
|
+
}
|
|
2657
|
+
var UUID2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
2658
|
+
var UnknownAccountError = class extends Error {
|
|
2659
|
+
};
|
|
2660
|
+
var LoginStore = class {
|
|
2661
|
+
options;
|
|
2662
|
+
sessions;
|
|
2663
|
+
legacy;
|
|
2664
|
+
loginHint;
|
|
2665
|
+
fetcher;
|
|
2666
|
+
devices;
|
|
2667
|
+
constructor(options) {
|
|
2668
|
+
this.options = options;
|
|
2669
|
+
this.loginHint = options.loginHint ?? loginContract.reader.login_hint;
|
|
2670
|
+
this.fetcher = options.fetcher ?? fetch;
|
|
2671
|
+
this.sessions = options.sessions ?? new FileSessionStore(void 0, this.loginHint);
|
|
2672
|
+
this.legacy = options.legacy ?? new FileCredentialStore(void 0, this.loginHint);
|
|
2673
|
+
this.devices = options.devices ?? new DeviceStore();
|
|
1989
2674
|
}
|
|
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"));
|
|
2675
|
+
/** The account the command named, if any. */
|
|
2676
|
+
get account() {
|
|
2677
|
+
return this.options.account;
|
|
1997
2678
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2679
|
+
/** The session client of this computer's CLI login; null without one. A login whose
|
|
2680
|
+
* device id hash is not this computer's is unusable here (credentials-v2.json
|
|
2681
|
+
* `reader.device`), except with `anyDevice`, whose client can only log out. */
|
|
2682
|
+
async session({ anyDevice = false } = {}) {
|
|
2683
|
+
const stored = await this.sessions.load();
|
|
2684
|
+
return stored ? this.client(stored, anyDevice) : null;
|
|
2000
2685
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2686
|
+
async client(stored, anyDevice = false) {
|
|
2687
|
+
const deviceId = await this.devices.deviceId();
|
|
2688
|
+
const here = deviceIdHash(deviceId) === stored.device_id_sha256;
|
|
2689
|
+
if (!here && !anyDevice)
|
|
2690
|
+
throw new UnusableLoginError(`The saved login at ${this.sessions.filename} was made on another computer. Run \`${this.loginHint}\` here.`);
|
|
2691
|
+
return SessionClient.for(stored, {
|
|
2692
|
+
fetcher: this.fetcher,
|
|
2693
|
+
loginOrigin: this.options.loginOrigin,
|
|
2694
|
+
apiOrigin: this.options.apiOrigin,
|
|
2695
|
+
loginHint: this.loginHint,
|
|
2696
|
+
now: this.options.now
|
|
2697
|
+
}, here ? deviceId : null);
|
|
2003
2698
|
}
|
|
2004
|
-
|
|
2005
|
-
|
|
2699
|
+
async load() {
|
|
2700
|
+
const stored = await this.sessions.load();
|
|
2701
|
+
if (stored)
|
|
2702
|
+
return this.view(await this.client(stored));
|
|
2703
|
+
const legacy = await this.legacy.load();
|
|
2704
|
+
return legacy && this.options.account ? legacyNamed(legacy, this.options.account) : legacy;
|
|
2006
2705
|
}
|
|
2007
|
-
/**
|
|
2008
|
-
|
|
2009
|
-
|
|
2706
|
+
/** Removes both files. */
|
|
2707
|
+
async remove() {
|
|
2708
|
+
await this.sessions.remove();
|
|
2709
|
+
await this.legacy.remove();
|
|
2010
2710
|
}
|
|
2011
|
-
/** The
|
|
2012
|
-
|
|
2013
|
-
|
|
2711
|
+
/** The person's accounts now, saved as the login's cache of names. */
|
|
2712
|
+
async refreshAccounts(client) {
|
|
2713
|
+
const accounts = await client.accounts();
|
|
2714
|
+
const latest = await this.sessions.load() ?? client.stored;
|
|
2715
|
+
if (latest.session.id === client.stored.session.id) {
|
|
2716
|
+
const saved = withAccounts(latest, accounts);
|
|
2717
|
+
await this.sessions.save(saved);
|
|
2718
|
+
client.stored = saved;
|
|
2719
|
+
}
|
|
2720
|
+
return accounts;
|
|
2014
2721
|
}
|
|
2015
|
-
|
|
2016
|
-
|
|
2722
|
+
/** The id of the account `nameOrId` names, looked up again once when the cache misses. */
|
|
2723
|
+
async resolve(client, nameOrId) {
|
|
2724
|
+
if (UUID2.test(nameOrId))
|
|
2725
|
+
return nameOrId;
|
|
2726
|
+
const find = (stored) => Object.entries(stored.accounts ?? {}).find(([, entry]) => entry.name.toLowerCase() === nameOrId.toLowerCase())?.[0];
|
|
2727
|
+
const cached = find(client.stored);
|
|
2728
|
+
if (cached)
|
|
2729
|
+
return cached;
|
|
2730
|
+
const found = (await this.refreshAccounts(client)).find((account) => account.name.toLowerCase() === nameOrId.toLowerCase());
|
|
2731
|
+
if (found)
|
|
2732
|
+
return found.id;
|
|
2733
|
+
throw new UnknownAccountError(`You belong to no account named ${nameOrId}. \`tonbo account list\` lists yours.`);
|
|
2017
2734
|
}
|
|
2018
|
-
|
|
2019
|
-
|
|
2735
|
+
/** The session as one login per account, the top level the command's account. */
|
|
2736
|
+
async view(client) {
|
|
2737
|
+
const stored = client.stored;
|
|
2738
|
+
const chosen = this.options.account ? await this.resolve(client, this.options.account) : stored.default_account_id ?? stored.personal_account_id;
|
|
2739
|
+
const current = client.stored;
|
|
2740
|
+
const accounts = {};
|
|
2741
|
+
const entry = (id) => ({
|
|
2742
|
+
key_id: `session:${current.session.id}`,
|
|
2743
|
+
key: client.bearer(id),
|
|
2744
|
+
expires_at: current.session.expires_at,
|
|
2745
|
+
kind: current.accounts?.[id]?.kind ?? (id === current.personal_account_id ? "user" : null),
|
|
2746
|
+
name: current.accounts?.[id]?.name ?? null
|
|
2747
|
+
});
|
|
2748
|
+
accounts[current.personal_account_id] = entry(current.personal_account_id);
|
|
2749
|
+
for (const id of Object.keys(current.accounts ?? {}))
|
|
2750
|
+
accounts[id] = entry(id);
|
|
2751
|
+
if (!accounts[chosen])
|
|
2752
|
+
accounts[chosen] = entry(chosen);
|
|
2753
|
+
const top = accounts[chosen];
|
|
2754
|
+
return {
|
|
2755
|
+
version: 1,
|
|
2756
|
+
account_id: chosen,
|
|
2757
|
+
key_id: top.key_id,
|
|
2758
|
+
key: top.key,
|
|
2759
|
+
expires_at: top.expires_at,
|
|
2760
|
+
accounts,
|
|
2761
|
+
session: {
|
|
2762
|
+
id: current.session.id,
|
|
2763
|
+
userId: current.user_id,
|
|
2764
|
+
deviceId: client.presentedDevice(),
|
|
2765
|
+
personalAccountId: current.personal_account_id,
|
|
2766
|
+
defaultAccountId: current.default_account_id ?? null,
|
|
2767
|
+
expiresAt: current.session.expires_at
|
|
2768
|
+
}
|
|
2769
|
+
};
|
|
2020
2770
|
}
|
|
2021
|
-
/**
|
|
2022
|
-
|
|
2023
|
-
|
|
2771
|
+
/**
|
|
2772
|
+
* With TONBO_API_KEY, an account the command named must be the key's own,
|
|
2773
|
+
* checked before anything else is called (credentials-v2.json `account`).
|
|
2774
|
+
*/
|
|
2775
|
+
async checkKeyAccount(key) {
|
|
2776
|
+
const named = this.options.account;
|
|
2777
|
+
if (!named)
|
|
2778
|
+
return;
|
|
2779
|
+
const response = await this.fetcher(`${this.options.apiOrigin}${managementApiBasePath}${managementOperations.getAccount.path}`, {
|
|
2780
|
+
redirect: "manual",
|
|
2781
|
+
signal: AbortSignal.timeout(3e4),
|
|
2782
|
+
headers: { authorization: `Bearer ${key}`, accept: "application/json" }
|
|
2783
|
+
});
|
|
2784
|
+
const body = await response.json().catch(() => null);
|
|
2785
|
+
if (!response.ok)
|
|
2786
|
+
throw new Error(`TONBO_API_KEY was refused (HTTP ${response.status}).`);
|
|
2787
|
+
const account = body?.data;
|
|
2788
|
+
const matches = UUID2.test(named) ? account?.id === named : typeof account?.name === "string" && account.name.toLowerCase() === named.toLowerCase();
|
|
2789
|
+
if (!matches)
|
|
2790
|
+
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
2791
|
}
|
|
2025
|
-
|
|
2026
|
-
|
|
2792
|
+
};
|
|
2793
|
+
function legacyNamed(login, named) {
|
|
2794
|
+
const wanted = named.toLowerCase();
|
|
2795
|
+
const entry = Object.entries(login.accounts ?? {}).find(([id, value2]) => id === named || value2.name?.toLowerCase() === wanted);
|
|
2796
|
+
if (!entry && login.account_id !== named)
|
|
2797
|
+
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.`);
|
|
2798
|
+
if (!entry)
|
|
2799
|
+
return login;
|
|
2800
|
+
const [account_id, value] = entry;
|
|
2801
|
+
return {
|
|
2802
|
+
...login,
|
|
2803
|
+
account_id,
|
|
2804
|
+
key_id: value.key_id,
|
|
2805
|
+
key: value.key,
|
|
2806
|
+
expires_at: value.expires_at
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
2811
|
+
var exec = promisify2(execFile2);
|
|
2812
|
+
var DEFAULT_LOGIN_HINT3 = credentialsContract.reader.login_hint;
|
|
2813
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
2814
|
+
var BrowserUnavailableError = class extends Error {
|
|
2815
|
+
};
|
|
2816
|
+
function prefersDeviceLogin(env = process.env) {
|
|
2817
|
+
const ssh = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
|
|
2818
|
+
return ssh && !env.DISPLAY && !env.WAYLAND_DISPLAY;
|
|
2819
|
+
}
|
|
2820
|
+
function loginHostname(hostname = os4.hostname()) {
|
|
2821
|
+
const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
2822
|
+
return cleaned || "computer";
|
|
2823
|
+
}
|
|
2824
|
+
function clientFact(value) {
|
|
2825
|
+
return value && /^[A-Za-z0-9][A-Za-z0-9._+-]{0,31}$/.test(value) ? value : void 0;
|
|
2826
|
+
}
|
|
2827
|
+
var AuthClient = class {
|
|
2828
|
+
credentials;
|
|
2829
|
+
fetcher;
|
|
2830
|
+
loginOrigin;
|
|
2831
|
+
browserOpener;
|
|
2832
|
+
hostname;
|
|
2833
|
+
loginHint;
|
|
2834
|
+
entryLine;
|
|
2835
|
+
env;
|
|
2836
|
+
facts;
|
|
2837
|
+
keyChecked = null;
|
|
2838
|
+
constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT3, entryLine = "cli", env = process.env, facts = {}) {
|
|
2839
|
+
this.credentials = credentials;
|
|
2840
|
+
this.fetcher = fetcher;
|
|
2841
|
+
this.loginOrigin = loginOrigin2;
|
|
2842
|
+
this.browserOpener = browserOpener;
|
|
2843
|
+
this.hostname = hostname;
|
|
2844
|
+
this.loginHint = loginHint;
|
|
2845
|
+
this.entryLine = entryLine;
|
|
2846
|
+
this.env = env;
|
|
2847
|
+
this.facts = facts;
|
|
2027
2848
|
}
|
|
2028
2849
|
/**
|
|
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`).
|
|
2850
|
+
* The credential of the account a command acts in: TONBO_API_KEY (whose
|
|
2851
|
+
* own account any account the command named must be), or the CLI login's
|
|
2852
|
+
* bearer of the named account, else of its default, else of the personal
|
|
2853
|
+
* account; with only a legacy login, that file's key.
|
|
2037
2854
|
*/
|
|
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);
|
|
2855
|
+
async apiKey() {
|
|
2856
|
+
const injected = this.env.TONBO_API_KEY?.trim();
|
|
2857
|
+
if (injected) {
|
|
2858
|
+
if (this.keyChecked !== injected) {
|
|
2859
|
+
await this.credentials.checkKeyAccount(injected);
|
|
2860
|
+
this.keyChecked = injected;
|
|
2063
2861
|
}
|
|
2862
|
+
return injected;
|
|
2064
2863
|
}
|
|
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 } });
|
|
2864
|
+
const login = await this.credentials.load();
|
|
2865
|
+
if (!login)
|
|
2866
|
+
throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
|
|
2867
|
+
if (isExpiredLogin(login))
|
|
2868
|
+
throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
|
|
2869
|
+
return login.key;
|
|
2096
2870
|
}
|
|
2097
|
-
/**
|
|
2098
|
-
*
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2871
|
+
/**
|
|
2872
|
+
* Logs this computer in (ADR-0018): the browser handoff of /cli/login, PKCE
|
|
2873
|
+
* S256 with a loopback redirect on a free port, asks for a session
|
|
2874
|
+
* (`grant=cli_session`); the approval names this computer and asks no
|
|
2875
|
+
* account. With `device`, in an SSH session without a display, or when no
|
|
2876
|
+
* browser can be opened, the same PKCE challenge goes to a device-code
|
|
2877
|
+
* login instead: the person enters a short code at /cli/device in any
|
|
2878
|
+
* browser and the CLI polls. The redeemed session, tied to this
|
|
2879
|
+
* computer's device id, is saved as `login.json` and replaces this
|
|
2880
|
+
* computer's earlier session; the legacy `credentials.json` is left as it
|
|
2881
|
+
* is. With `account` (a name or id), commands act there by default unless
|
|
2882
|
+
* `current` is false. Answers the login of the account commands act in.
|
|
2883
|
+
*/
|
|
2884
|
+
async login(progress = () => {
|
|
2885
|
+
}, account, { current = true, device } = {}) {
|
|
2886
|
+
const deviceId = await (this.facts.devices ?? new DeviceStore()).deviceId();
|
|
2887
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
2888
|
+
const challenge = createHash3("sha256").update(verifier).digest("base64url");
|
|
2889
|
+
const redeem = {
|
|
2890
|
+
grant: cliSessionContract.grant,
|
|
2891
|
+
device_id: deviceId,
|
|
2892
|
+
client: this.facts.client ?? (this.entryLine === "yield" ? "yield" : "tonbo"),
|
|
2893
|
+
...clientFact(this.facts.clientVersion) ? { client_version: clientFact(this.facts.clientVersion) } : {},
|
|
2894
|
+
...clientFact(process.platform) ? { os: process.platform } : {}
|
|
2895
|
+
};
|
|
2896
|
+
const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, redeem, progress) : await this.browserLogin(challenge, verifier, redeem, progress).catch((error) => {
|
|
2897
|
+
if (!(error instanceof BrowserUnavailableError))
|
|
2898
|
+
throw error;
|
|
2899
|
+
progress({ status: "failed", step: "browser", reason: error.message });
|
|
2900
|
+
return this.deviceLogin(challenge, verifier, redeem, progress);
|
|
2103
2901
|
});
|
|
2104
|
-
|
|
2902
|
+
progress({ status: "started", step: "credential-store" });
|
|
2903
|
+
const previous = await this.credentials.sessions.load().catch(() => null);
|
|
2904
|
+
const wanted = account?.toLowerCase();
|
|
2905
|
+
const named = wanted ? redeemed.accounts.find((entry) => entry.id === account || entry.name.toLowerCase() === wanted) : void 0;
|
|
2906
|
+
const kept = previous?.user_id === redeemed.user_id ? previous.default_account_id ?? null : null;
|
|
2907
|
+
const chosen = named && current ? named.id === redeemed.personal_account_id ? null : named.id : kept;
|
|
2908
|
+
const stored = {
|
|
2909
|
+
version: 2,
|
|
2910
|
+
session: redeemed.session,
|
|
2911
|
+
user_id: redeemed.user_id,
|
|
2912
|
+
// The id itself stays in the device file (credentials-v2.json `device_id_sha256`).
|
|
2913
|
+
device_id_sha256: deviceIdHash(deviceId),
|
|
2914
|
+
personal_account_id: redeemed.personal_account_id,
|
|
2915
|
+
default_account_id: chosen && redeemed.accounts.some((entry) => entry.id === chosen) ? chosen : null,
|
|
2916
|
+
accounts: Object.fromEntries(redeemed.accounts.map((entry) => [
|
|
2917
|
+
entry.id,
|
|
2918
|
+
{ name: entry.name, kind: entry.kind, role: entry.role }
|
|
2919
|
+
]))
|
|
2920
|
+
};
|
|
2921
|
+
await this.credentials.sessions.save(stored);
|
|
2922
|
+
progress({ status: "completed", step: "credential-store" });
|
|
2923
|
+
if (account && !named)
|
|
2924
|
+
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.`);
|
|
2925
|
+
const view = await this.credentials.load();
|
|
2926
|
+
const actsIn = named?.id ?? stored.default_account_id ?? stored.personal_account_id;
|
|
2927
|
+
const login = view && accountLogins(view).find((entry) => entry.account_id === actsIn);
|
|
2928
|
+
if (!login)
|
|
2929
|
+
throw new Error("The saved login could not be read back.");
|
|
2930
|
+
return login;
|
|
2105
2931
|
}
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2932
|
+
async browserLogin(challenge, verifier, redeem, progress) {
|
|
2933
|
+
const state = randomBytes(24).toString("base64url");
|
|
2934
|
+
const code = await this.listenForCode(state, (redirectUri) => {
|
|
2935
|
+
const authorization = new URL("/cli/login", this.loginOrigin);
|
|
2936
|
+
authorization.search = new URLSearchParams({
|
|
2937
|
+
code_challenge: challenge,
|
|
2938
|
+
code_challenge_method: "S256",
|
|
2939
|
+
redirect_uri: redirectUri,
|
|
2940
|
+
state,
|
|
2941
|
+
hostname: this.hostname,
|
|
2942
|
+
from: this.entryLine,
|
|
2943
|
+
[cliSessionContract.grantParameter]: cliSessionContract.grant
|
|
2944
|
+
}).toString();
|
|
2945
|
+
return authorization.toString();
|
|
2946
|
+
}, progress);
|
|
2947
|
+
progress({ status: "started", step: "key-redemption" });
|
|
2948
|
+
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
2949
|
+
method: "POST",
|
|
2950
|
+
headers: { "content-type": "application/json" },
|
|
2951
|
+
body: JSON.stringify({ code, code_verifier: verifier, ...redeem })
|
|
2114
2952
|
});
|
|
2953
|
+
progress({ status: "completed", step: "key-redemption" });
|
|
2954
|
+
return redeemed;
|
|
2115
2955
|
}
|
|
2116
|
-
/**
|
|
2117
|
-
*
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2956
|
+
/**
|
|
2957
|
+
* The device-code login (RFC 8628's shape): the PKCE challenge and the
|
|
2958
|
+
* hostname buy a device code and a user code; the person approves the user
|
|
2959
|
+
* code at /cli/device, seeing the hostname; the CLI polls with the device
|
|
2960
|
+
* code, its verifier and this computer's facts, all in POST bodies, and
|
|
2961
|
+
* the approved poll answers the session once. A denied or expired code
|
|
2962
|
+
* ends it.
|
|
2963
|
+
*/
|
|
2964
|
+
async deviceLogin(challenge, verifier, redeem, progress) {
|
|
2965
|
+
progress({ status: "started", step: "device-code" });
|
|
2966
|
+
const started = await requestJson(this.fetcher, new URL("/api/cli/login/device", this.loginOrigin).toString(), {
|
|
2967
|
+
method: "POST",
|
|
2968
|
+
headers: { "content-type": "application/json" },
|
|
2969
|
+
body: JSON.stringify({
|
|
2970
|
+
code_challenge: challenge,
|
|
2971
|
+
code_challenge_method: "S256",
|
|
2972
|
+
hostname: this.hostname,
|
|
2973
|
+
[cliSessionContract.grantParameter]: cliSessionContract.grant
|
|
2974
|
+
})
|
|
2124
2975
|
});
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2976
|
+
progress({ status: "completed", step: "device-code" });
|
|
2977
|
+
const page = new URL("/cli/device", this.loginOrigin);
|
|
2978
|
+
const complete = new URL(page);
|
|
2979
|
+
complete.search = new URLSearchParams({
|
|
2980
|
+
code: started.user_code,
|
|
2981
|
+
from: this.entryLine
|
|
2982
|
+
}).toString();
|
|
2983
|
+
progress({
|
|
2984
|
+
status: "device-code",
|
|
2985
|
+
step: "device-authorization",
|
|
2986
|
+
userCode: started.user_code,
|
|
2987
|
+
url: page.toString(),
|
|
2988
|
+
completeUrl: complete.toString(),
|
|
2989
|
+
expiresIn: started.expires_in
|
|
2137
2990
|
});
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2991
|
+
progress({ status: "started", step: "device-authorization" });
|
|
2992
|
+
const expired = new Error(`The login code expired. Run \`${this.loginHint}\` again.`);
|
|
2993
|
+
const deadline = Date.now() + started.expires_in * 1e3;
|
|
2994
|
+
let interval = started.interval;
|
|
2995
|
+
for (; ; ) {
|
|
2996
|
+
await new Promise((resolve) => setTimeout(resolve, interval * 1e3));
|
|
2997
|
+
try {
|
|
2998
|
+
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/device/token", this.loginOrigin).toString(), {
|
|
2999
|
+
method: "POST",
|
|
3000
|
+
headers: { "content-type": "application/json" },
|
|
3001
|
+
body: JSON.stringify({
|
|
3002
|
+
device_code: started.device_code,
|
|
3003
|
+
code_verifier: verifier,
|
|
3004
|
+
...redeem
|
|
3005
|
+
})
|
|
3006
|
+
});
|
|
3007
|
+
progress({ status: "completed", step: "device-authorization" });
|
|
3008
|
+
return redeemed;
|
|
3009
|
+
} catch (error) {
|
|
3010
|
+
const code = error instanceof HttpError ? error.code : null;
|
|
3011
|
+
if (code === "slow_down")
|
|
3012
|
+
interval += 5;
|
|
3013
|
+
else if (code === "access_denied")
|
|
3014
|
+
throw new Error("The login was not approved (access_denied).");
|
|
3015
|
+
else if (code === "expired_token")
|
|
3016
|
+
throw expired;
|
|
3017
|
+
else if (code !== "authorization_pending" && !transient(error))
|
|
3018
|
+
throw error;
|
|
3019
|
+
if (Date.now() >= deadline)
|
|
3020
|
+
throw expired;
|
|
3021
|
+
}
|
|
3022
|
+
}
|
|
2141
3023
|
}
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
3024
|
+
listenForCode(expectedState, authorizationUrl, progress) {
|
|
3025
|
+
return new Promise((resolve, reject) => {
|
|
3026
|
+
let settled = false;
|
|
3027
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
3028
|
+
let authorizationStarted = false;
|
|
3029
|
+
const startAuthorization = () => {
|
|
3030
|
+
if (authorizationStarted)
|
|
3031
|
+
return;
|
|
3032
|
+
authorizationStarted = true;
|
|
3033
|
+
progress({ status: "completed", step: "browser" });
|
|
3034
|
+
progress({ status: "started", step: "browser-authorization" });
|
|
3035
|
+
};
|
|
3036
|
+
const finish = (result, completedResponseSocket) => {
|
|
3037
|
+
if (settled)
|
|
3038
|
+
return;
|
|
3039
|
+
settled = true;
|
|
3040
|
+
clearTimeout(timeout);
|
|
3041
|
+
const complete = (closeError) => {
|
|
3042
|
+
if (closeError)
|
|
3043
|
+
reject(closeError);
|
|
3044
|
+
else if ("code" in result) {
|
|
3045
|
+
progress({ status: "completed", step: "callback-close" });
|
|
3046
|
+
resolve(result.code);
|
|
3047
|
+
} else
|
|
3048
|
+
reject(result.error);
|
|
3049
|
+
};
|
|
3050
|
+
if ("code" in result)
|
|
3051
|
+
progress({ status: "started", step: "callback-close" });
|
|
3052
|
+
if (!server.listening) {
|
|
3053
|
+
complete();
|
|
3054
|
+
return;
|
|
3055
|
+
}
|
|
3056
|
+
server.close(complete);
|
|
3057
|
+
for (const socket of sockets) {
|
|
3058
|
+
if (socket !== completedResponseSocket)
|
|
3059
|
+
socket.destroy();
|
|
3060
|
+
}
|
|
3061
|
+
server.closeIdleConnections();
|
|
3062
|
+
};
|
|
3063
|
+
const timeout = setTimeout(() => {
|
|
3064
|
+
finish({ error: new Error("Timed out waiting for browser login.") });
|
|
3065
|
+
}, 5 * 60 * 1e3);
|
|
3066
|
+
const server = createServer((request, response) => {
|
|
3067
|
+
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
3068
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
|
|
3069
|
+
respond(response, "invalid");
|
|
3070
|
+
return;
|
|
3071
|
+
}
|
|
3072
|
+
const denied = url.searchParams.get("error");
|
|
3073
|
+
if (denied) {
|
|
3074
|
+
const responseSocket2 = response.socket;
|
|
3075
|
+
respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
|
|
3076
|
+
return;
|
|
3077
|
+
}
|
|
3078
|
+
const code = url.searchParams.get("code");
|
|
3079
|
+
if (!code) {
|
|
3080
|
+
respond(response, "invalid");
|
|
3081
|
+
return;
|
|
3082
|
+
}
|
|
3083
|
+
startAuthorization();
|
|
3084
|
+
progress({ status: "completed", step: "browser-authorization" });
|
|
3085
|
+
const responseSocket = response.socket;
|
|
3086
|
+
respond(response, "complete", () => finish({ code }, responseSocket));
|
|
3087
|
+
});
|
|
3088
|
+
server.on("connection", (socket) => {
|
|
3089
|
+
sockets.add(socket);
|
|
3090
|
+
socket.once("close", () => sockets.delete(socket));
|
|
3091
|
+
});
|
|
3092
|
+
server.once("error", (error) => finish({ error }));
|
|
3093
|
+
progress({ status: "started", step: "callback-server" });
|
|
3094
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
3095
|
+
const { port } = server.address();
|
|
3096
|
+
progress({ status: "completed", step: "callback-server" });
|
|
3097
|
+
const url = authorizationUrl(`http://${LOOPBACK_HOST}:${port}/callback`);
|
|
3098
|
+
progress({ status: "authorization-url", step: "browser", url });
|
|
3099
|
+
progress({ status: "started", step: "browser" });
|
|
3100
|
+
void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
|
|
3101
|
+
if (settled || authorizationStarted)
|
|
3102
|
+
return;
|
|
3103
|
+
finish({
|
|
3104
|
+
error: new BrowserUnavailableError(error instanceof Error ? error.message : String(error))
|
|
3105
|
+
});
|
|
3106
|
+
});
|
|
3107
|
+
});
|
|
2148
3108
|
});
|
|
2149
3109
|
}
|
|
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
3110
|
};
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
function retryAfterSeconds(value) {
|
|
2174
|
-
if (value === null || !/^[0-9]+$/.test(value.trim()))
|
|
2175
|
-
return null;
|
|
2176
|
-
return Number(value.trim());
|
|
3111
|
+
function transient(error) {
|
|
3112
|
+
return error instanceof HttpError ? error.status === 429 || error.status >= 500 : error instanceof TypeError;
|
|
2177
3113
|
}
|
|
2178
|
-
function
|
|
2179
|
-
|
|
3114
|
+
function respond(response, outcome, finished) {
|
|
3115
|
+
const page = callbackResponse(outcome);
|
|
3116
|
+
if (finished)
|
|
3117
|
+
response.once("finish", finished);
|
|
3118
|
+
response.writeHead(page.status, { ...page.headers, connection: "close" });
|
|
3119
|
+
response.end(page.body);
|
|
2180
3120
|
}
|
|
2181
|
-
function
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
3121
|
+
function browserCommand(url, platform = process.platform, env = process.env) {
|
|
3122
|
+
if (platform === "darwin")
|
|
3123
|
+
return ["open", [url]];
|
|
3124
|
+
if (platform === "win32")
|
|
3125
|
+
return ["rundll32", ["url.dll,FileProtocolHandler", url]];
|
|
3126
|
+
if (!env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
3127
|
+
throw new Error("there is no graphical session (DISPLAY and WAYLAND_DISPLAY are unset)");
|
|
3128
|
+
return ["xdg-open", [url]];
|
|
3129
|
+
}
|
|
3130
|
+
async function openBrowser(url) {
|
|
3131
|
+
const [command, args] = browserCommand(url);
|
|
3132
|
+
try {
|
|
3133
|
+
await exec(command, args);
|
|
3134
|
+
} catch (error) {
|
|
3135
|
+
const code = error.code;
|
|
3136
|
+
if (code === "ENOENT")
|
|
3137
|
+
throw new Error(`${command} is not installed`, { cause: error });
|
|
3138
|
+
if (typeof code === "number")
|
|
3139
|
+
throw new Error(`${command} exited with code ${code}`, { cause: error });
|
|
3140
|
+
throw error;
|
|
3141
|
+
}
|
|
2187
3142
|
}
|
|
2188
3143
|
|
|
2189
3144
|
// ../../packages/tonbo-cli-core/dist/src/format.js
|
|
@@ -2228,7 +3183,12 @@ function when(timestamp, absent = "never") {
|
|
|
2228
3183
|
async function loginCommand(deps, options = {}) {
|
|
2229
3184
|
try {
|
|
2230
3185
|
const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current, device: options.device });
|
|
2231
|
-
|
|
3186
|
+
const file = await deps.credentials.load();
|
|
3187
|
+
const names = file ? accountLogins(file).map((entry) => entry.kind === "user" ? `${entry.name ?? "you"} (personal)` : entry.name ?? entry.account_id) : [];
|
|
3188
|
+
deps.output([
|
|
3189
|
+
`Logged in. This computer can act in every account you belong to, with your role in each${names.length ? `: ${names.join(", ")}` : ""}.`,
|
|
3190
|
+
`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.` : ""}`
|
|
3191
|
+
].join("\n"));
|
|
2232
3192
|
return login;
|
|
2233
3193
|
} catch (error) {
|
|
2234
3194
|
deps.progress.fail();
|
|
@@ -2253,8 +3213,8 @@ var LOGIN_PROGRESS_MESSAGES = {
|
|
|
2253
3213
|
completed: "Local browser callback closed"
|
|
2254
3214
|
},
|
|
2255
3215
|
"key-redemption": {
|
|
2256
|
-
started: "Receiving the CLI
|
|
2257
|
-
completed: "CLI
|
|
3216
|
+
started: "Receiving the CLI login",
|
|
3217
|
+
completed: "CLI login received"
|
|
2258
3218
|
},
|
|
2259
3219
|
"device-code": {
|
|
2260
3220
|
started: "Requesting a login code",
|
|
@@ -2291,37 +3251,46 @@ function reportLoginProgress(progress, event) {
|
|
|
2291
3251
|
progress.succeed(messages.completed);
|
|
2292
3252
|
}
|
|
2293
3253
|
async function logoutCommand(deps) {
|
|
2294
|
-
|
|
3254
|
+
const failures = [];
|
|
3255
|
+
let first;
|
|
3256
|
+
const failed = (what, error) => {
|
|
3257
|
+
const failure = error instanceof Error ? error : new Error("The revocation failed.");
|
|
3258
|
+
first ??= failure;
|
|
3259
|
+
failures.push(`${what}: ${failure.message}`);
|
|
3260
|
+
};
|
|
3261
|
+
let session = null;
|
|
2295
3262
|
try {
|
|
2296
|
-
|
|
3263
|
+
session = await deps.credentials.session({ anyDevice: true });
|
|
2297
3264
|
} catch (error) {
|
|
2298
3265
|
if (!(error instanceof UnusableLoginError))
|
|
2299
3266
|
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
3267
|
}
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
3268
|
+
let legacy = null;
|
|
3269
|
+
try {
|
|
3270
|
+
legacy = await deps.credentials.legacy.load();
|
|
3271
|
+
} catch (error) {
|
|
3272
|
+
if (!(error instanceof UnusableLoginError))
|
|
3273
|
+
throw error;
|
|
2307
3274
|
}
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
for (const entry of accountLogins(
|
|
3275
|
+
if (session)
|
|
3276
|
+
await session.logout().catch((error) => failed("the CLI login", error));
|
|
3277
|
+
for (const entry of legacy ? accountLogins(legacy) : []) {
|
|
2311
3278
|
try {
|
|
2312
3279
|
await deps.api.revokeApiKey(entry.key, entry.key_id);
|
|
2313
3280
|
} catch (error) {
|
|
2314
3281
|
if (error instanceof HttpError && (error.status === 401 || error.status === 404))
|
|
2315
3282
|
continue;
|
|
2316
|
-
|
|
2317
|
-
first ??= failure;
|
|
2318
|
-
failures.push(`${entry.key_id}: ${failure.message}`);
|
|
3283
|
+
failed(entry.key_id, error);
|
|
2319
3284
|
}
|
|
2320
3285
|
}
|
|
2321
3286
|
await deps.credentials.remove();
|
|
3287
|
+
if (!session && !legacy) {
|
|
3288
|
+
deps.output("Not logged in.");
|
|
3289
|
+
return;
|
|
3290
|
+
}
|
|
2322
3291
|
if (failures.length)
|
|
2323
|
-
throw new Error(`Removed the saved login, but could not revoke ${failures.
|
|
2324
|
-
deps.output(
|
|
3292
|
+
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 });
|
|
3293
|
+
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
3294
|
}
|
|
2326
3295
|
|
|
2327
3296
|
// ../../packages/tonbo-cli-core/dist/src/origin.js
|
|
@@ -2410,7 +3379,7 @@ var TerminalProgress = class {
|
|
|
2410
3379
|
};
|
|
2411
3380
|
|
|
2412
3381
|
// src/commands.ts
|
|
2413
|
-
import { randomUUID as
|
|
3382
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
2414
3383
|
|
|
2415
3384
|
// src/agent-name.ts
|
|
2416
3385
|
var AGENT_NAME_RULE = agentNameContract.$defs.name;
|
|
@@ -2467,7 +3436,7 @@ async function agentCreateCommand(deps, name, options = {}) {
|
|
|
2467
3436
|
const agent = await deps.api.createAgent(
|
|
2468
3437
|
key,
|
|
2469
3438
|
{ name, region, ...options.role === void 0 ? {} : { role: options.role } },
|
|
2470
|
-
options.idempotencyKey ??
|
|
3439
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2471
3440
|
);
|
|
2472
3441
|
print(
|
|
2473
3442
|
deps,
|
|
@@ -2756,7 +3725,7 @@ async function bucketCreateCommand(deps, name, options = {}) {
|
|
|
2756
3725
|
const bucket = await deps.api.createBucket(
|
|
2757
3726
|
await deps.auth.apiKey(),
|
|
2758
3727
|
{ name, region: options.region },
|
|
2759
|
-
options.idempotencyKey ??
|
|
3728
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2760
3729
|
);
|
|
2761
3730
|
print(
|
|
2762
3731
|
deps,
|
|
@@ -2887,7 +3856,7 @@ async function databaseCreateCommand(deps, name, options = {}) {
|
|
|
2887
3856
|
const database = await deps.api.createDatabase(
|
|
2888
3857
|
await deps.auth.apiKey(),
|
|
2889
3858
|
{ name, region: options.region, expires_at: expiresAt },
|
|
2890
|
-
options.idempotencyKey ??
|
|
3859
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2891
3860
|
);
|
|
2892
3861
|
print(
|
|
2893
3862
|
deps,
|
|
@@ -3075,7 +4044,7 @@ async function streamCreateCommand(deps, name, options = {}) {
|
|
|
3075
4044
|
content_type: options.contentType,
|
|
3076
4045
|
expires_at: expiresAt
|
|
3077
4046
|
},
|
|
3078
|
-
options.idempotencyKey ??
|
|
4047
|
+
options.idempotencyKey ?? randomUUID3()
|
|
3079
4048
|
);
|
|
3080
4049
|
print(
|
|
3081
4050
|
deps,
|
|
@@ -3238,14 +4207,71 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
3238
4207
|
);
|
|
3239
4208
|
}
|
|
3240
4209
|
|
|
4210
|
+
// src/account.ts
|
|
4211
|
+
async function sessionOf(deps, command) {
|
|
4212
|
+
const session = await deps.credentials.session();
|
|
4213
|
+
if (!session)
|
|
4214
|
+
throw new Error(
|
|
4215
|
+
`\`${command}\` needs the CLI login that reaches every account you belong to. Run \`tonbo login\`.`
|
|
4216
|
+
);
|
|
4217
|
+
return session;
|
|
4218
|
+
}
|
|
4219
|
+
async function accountListCommand(deps, options = {}) {
|
|
4220
|
+
if (process.env.TONBO_API_KEY?.trim())
|
|
4221
|
+
throw new Error(
|
|
4222
|
+
"TONBO_API_KEY reaches only its own account (`tonbo account show`). Unset it to list the accounts your login reaches."
|
|
4223
|
+
);
|
|
4224
|
+
const session = await sessionOf(deps, "tonbo account list");
|
|
4225
|
+
const accounts = await deps.credentials.refreshAccounts(session);
|
|
4226
|
+
const chosen = session.stored.default_account_id ?? session.stored.personal_account_id;
|
|
4227
|
+
const rows = accounts.map((account) => ({
|
|
4228
|
+
...account,
|
|
4229
|
+
default: account.id === chosen
|
|
4230
|
+
}));
|
|
4231
|
+
print(
|
|
4232
|
+
deps,
|
|
4233
|
+
options.json,
|
|
4234
|
+
rows,
|
|
4235
|
+
table(
|
|
4236
|
+
["", "NAME", "KIND", "YOUR ROLE", "ID"],
|
|
4237
|
+
rows.map((row) => [row.default ? "*" : "", row.name, row.kind, row.role, row.id])
|
|
4238
|
+
)
|
|
4239
|
+
);
|
|
4240
|
+
}
|
|
4241
|
+
async function accountUseCommand(deps, account, options = {}) {
|
|
4242
|
+
const session = await sessionOf(deps, "tonbo account use");
|
|
4243
|
+
const wanted = account.toLowerCase();
|
|
4244
|
+
const accounts = await deps.credentials.refreshAccounts(session);
|
|
4245
|
+
const found = accounts.find(
|
|
4246
|
+
(entry) => entry.id === account || entry.name.toLowerCase() === wanted
|
|
4247
|
+
);
|
|
4248
|
+
if (!found)
|
|
4249
|
+
throw new UnknownAccountError(
|
|
4250
|
+
`You belong to no account named ${account}. \`tonbo account list\` lists yours.`
|
|
4251
|
+
);
|
|
4252
|
+
const stored = session.stored;
|
|
4253
|
+
const saved = {
|
|
4254
|
+
...stored,
|
|
4255
|
+
default_account_id: found.id === stored.personal_account_id ? null : found.id
|
|
4256
|
+
};
|
|
4257
|
+
await deps.credentials.sessions.save(saved);
|
|
4258
|
+
session.stored = saved;
|
|
4259
|
+
print(
|
|
4260
|
+
deps,
|
|
4261
|
+
options.json,
|
|
4262
|
+
{ ...found, default: true },
|
|
4263
|
+
`Commands act in ${found.kind === "user" ? `${found.name}, your personal account,` : found.name} from now on, unless you pass --account.`
|
|
4264
|
+
);
|
|
4265
|
+
}
|
|
4266
|
+
|
|
3241
4267
|
// src/help.ts
|
|
3242
4268
|
import { Help } from "commander";
|
|
3243
4269
|
|
|
3244
4270
|
// src/machine.ts
|
|
3245
|
-
import { randomUUID as
|
|
4271
|
+
import { randomUUID as randomUUID5 } from "node:crypto";
|
|
3246
4272
|
|
|
3247
4273
|
// src/volume.ts
|
|
3248
|
-
import { randomUUID as
|
|
4274
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
3249
4275
|
var VOLUME_DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees/data#volume";
|
|
3250
4276
|
var VOLUME_ERROR_HINTS = {
|
|
3251
4277
|
payment_required: "Add credit at <billing>, then create the Volume again.",
|
|
@@ -3331,7 +4357,7 @@ async function volumeCreateCommand(deps, name, options = {}) {
|
|
|
3331
4357
|
const volume = await deps.api.createVolume(
|
|
3332
4358
|
key,
|
|
3333
4359
|
{ name, region, size_gib: sizeGib },
|
|
3334
|
-
options.idempotencyKey ??
|
|
4360
|
+
options.idempotencyKey ?? randomUUID4()
|
|
3335
4361
|
);
|
|
3336
4362
|
print(
|
|
3337
4363
|
deps,
|
|
@@ -3389,8 +4415,8 @@ var MACHINE_ERROR_HINTS = {
|
|
|
3389
4415
|
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
4416
|
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
4417
|
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
4418
|
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.",
|
|
4419
|
+
database_bound: "Another Machine binds that database, and a database binds one Machine: name another, or delete the Machine that binds it first.",
|
|
3394
4420
|
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
3395
4421
|
create_rate_limited: "Too many Machines were created in the last hour; try again later.",
|
|
3396
4422
|
image_pulling: "The Machine's image is still being pulled; the request was not run. Try again shortly; `tonbo machine show` reports the image's state.",
|
|
@@ -3405,8 +4431,8 @@ var MACHINE_IMAGE_FAILURE_HINTS = {
|
|
|
3405
4431
|
image_unsupported: "Rebuild the image without the entry the message names"
|
|
3406
4432
|
};
|
|
3407
4433
|
var MACHINE_CREATION_FAILURE_HINTS = {
|
|
3408
|
-
database_creation_failed: "
|
|
3409
|
-
database_limit_reached: "
|
|
4434
|
+
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.",
|
|
4435
|
+
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
4436
|
payment_required: "The account's credit is exhausted: add credit at <billing>, then delete the Machine and create it again.",
|
|
3411
4437
|
...Object.fromEntries(
|
|
3412
4438
|
Object.entries(MACHINE_IMAGE_FAILURE_HINTS).map(([code, hint]) => [
|
|
@@ -3596,7 +4622,7 @@ function machineFields(machine, billing, etag = null, lastEnd = null) {
|
|
|
3596
4622
|
["Stop grace", `${machine.stop_grace_seconds} s`],
|
|
3597
4623
|
["Volume", volumeText(machine)],
|
|
3598
4624
|
...deleteFields(machine),
|
|
3599
|
-
["Database", `${machine.database.name} (${machine.database.url})`],
|
|
4625
|
+
["Database", machine.database ? `${machine.database.name} (${machine.database.url})` : "none"],
|
|
3600
4626
|
["Logs", machine.logs.url],
|
|
3601
4627
|
["Durability", DURABILITY_LINK],
|
|
3602
4628
|
["Revision", String(machine.revision)],
|
|
@@ -3854,7 +4880,12 @@ async function machineCreateCommand(deps, name, command, options = {}) {
|
|
|
3854
4880
|
const ports = withDefault(parsePorts(options.port ?? []), options.defaultPort);
|
|
3855
4881
|
const key = await deps.auth.apiKey();
|
|
3856
4882
|
const bound = typeof options.volume === "string" ? await resolveVolumeBinding(deps, key, options.volume) : null;
|
|
3857
|
-
const
|
|
4883
|
+
const database = typeof options.database === "string" ? await deps.api.getDatabase(key, options.database) : null;
|
|
4884
|
+
if (database && bound && database.region !== bound.region && !options.region)
|
|
4885
|
+
throw new Error(
|
|
4886
|
+
`The Volume is in ${bound.region} and database ${database.name} in ${database.region}; a Machine binds both only in its own region.`
|
|
4887
|
+
);
|
|
4888
|
+
const region = options.region ?? bound?.region ?? database?.region;
|
|
3858
4889
|
const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
|
|
3859
4890
|
let created;
|
|
3860
4891
|
try {
|
|
@@ -3872,9 +4903,11 @@ async function machineCreateCommand(deps, name, command, options = {}) {
|
|
|
3872
4903
|
idle_action: options.idleAction,
|
|
3873
4904
|
stop_grace_seconds: options.stopGrace,
|
|
3874
4905
|
...bound ? { volumes: [bound.binding] } : {},
|
|
3875
|
-
delete_after_seconds: deleteAfterSeconds(options.deleteAfter)
|
|
4906
|
+
delete_after_seconds: deleteAfterSeconds(options.deleteAfter),
|
|
4907
|
+
...database ? { database: database.name } : {},
|
|
4908
|
+
...options.database === false ? { database: null } : {}
|
|
3876
4909
|
},
|
|
3877
|
-
options.idempotencyKey ??
|
|
4910
|
+
options.idempotencyKey ?? randomUUID5()
|
|
3878
4911
|
);
|
|
3879
4912
|
} catch (error) {
|
|
3880
4913
|
if (error instanceof HttpError && error.code === "name_taken")
|
|
@@ -4104,9 +5137,11 @@ async function machineDeleteCommand(deps, name, options = {}) {
|
|
|
4104
5137
|
}
|
|
4105
5138
|
async function deleteQuestion(deps, key, name, read) {
|
|
4106
5139
|
let kind = null;
|
|
5140
|
+
let ownDatabase = false;
|
|
4107
5141
|
try {
|
|
4108
5142
|
const answer = await deps.api.getMachineWithEtag(key, name);
|
|
4109
5143
|
kind = answer.machine.kind;
|
|
5144
|
+
ownDatabase = answer.machine.kind === "managed" && !!answer.machine.database?.name.startsWith("m.");
|
|
4110
5145
|
read.etag = answer.etag;
|
|
4111
5146
|
} catch (error) {
|
|
4112
5147
|
if (!(error instanceof HttpError && error.status === 404)) throw error;
|
|
@@ -4114,7 +5149,7 @@ async function deleteQuestion(deps, key, name, read) {
|
|
|
4114
5149
|
if (kind === "byoc")
|
|
4115
5150
|
return `Forget your computer ${name}? It disappears from your Machines; its key is not revoked. Type its name to confirm:`;
|
|
4116
5151
|
if (kind === "managed")
|
|
4117
|
-
return `Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`;
|
|
5152
|
+
return `Delete Machine ${name} with its disk${ownDatabase ? ", database" : ""} and logs? This cannot be undone. Type the Machine's name to confirm:`;
|
|
4118
5153
|
return `Delete Machine ${name}? Type its name to confirm:`;
|
|
4119
5154
|
}
|
|
4120
5155
|
function deletedText(name, machine, kind) {
|
|
@@ -4154,7 +5189,11 @@ async function machineShapesCommand(deps, options = {}) {
|
|
|
4154
5189
|
var ENVIRONMENT = [
|
|
4155
5190
|
{
|
|
4156
5191
|
names: ["TONBO_API_KEY"],
|
|
4157
|
-
description: "
|
|
5192
|
+
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."
|
|
5193
|
+
},
|
|
5194
|
+
{
|
|
5195
|
+
names: ["TONBO_ACCOUNT"],
|
|
5196
|
+
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."
|
|
4158
5197
|
},
|
|
4159
5198
|
{
|
|
4160
5199
|
names: ["TONBO_API_ORIGIN"],
|
|
@@ -4162,7 +5201,11 @@ var ENVIRONMENT = [
|
|
|
4162
5201
|
},
|
|
4163
5202
|
{
|
|
4164
5203
|
names: ["XDG_CONFIG_HOME"],
|
|
4165
|
-
description: "Where the login file lives: `$XDG_CONFIG_HOME/tonbo/
|
|
5204
|
+
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`."
|
|
5205
|
+
},
|
|
5206
|
+
{
|
|
5207
|
+
names: ["XDG_STATE_HOME"],
|
|
5208
|
+
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."
|
|
4166
5209
|
},
|
|
4167
5210
|
{
|
|
4168
5211
|
names: ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"],
|
|
@@ -4198,10 +5241,11 @@ var TOPICS = [
|
|
|
4198
5241
|
},
|
|
4199
5242
|
{
|
|
4200
5243
|
id: "credentials",
|
|
4201
|
-
title: "
|
|
5244
|
+
title: "The login, accounts and keys",
|
|
4202
5245
|
text: [
|
|
4203
|
-
"
|
|
4204
|
-
"
|
|
5246
|
+
"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.",
|
|
5247
|
+
"`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.",
|
|
5248
|
+
"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."
|
|
4205
5249
|
]
|
|
4206
5250
|
},
|
|
4207
5251
|
{
|
|
@@ -4238,24 +5282,37 @@ var NAME = (noun) => ({ name: `the ${noun}'s name` });
|
|
|
4238
5282
|
var COMMAND_HELP = {
|
|
4239
5283
|
"tonbo login": {
|
|
4240
5284
|
about: [
|
|
4241
|
-
"Prints the login URL and opens the browser at `https://tonbo.dev/cli/login`,
|
|
4242
|
-
"
|
|
4243
|
-
"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
|
|
5285
|
+
"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.",
|
|
5286
|
+
"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.",
|
|
5287
|
+
"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."
|
|
4244
5288
|
],
|
|
4245
|
-
examples: ["tonbo login", "tonbo login --device"]
|
|
4246
|
-
defaults: { "--account": "your personal account" }
|
|
5289
|
+
examples: ["tonbo login", "tonbo login --device", "tonbo login --account acme"]
|
|
4247
5290
|
},
|
|
4248
5291
|
"tonbo logout": {
|
|
4249
5292
|
about: [
|
|
4250
|
-
"
|
|
5293
|
+
"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."
|
|
4251
5294
|
],
|
|
4252
5295
|
examples: ["tonbo logout"]
|
|
4253
5296
|
},
|
|
4254
5297
|
"tonbo account show": {
|
|
4255
5298
|
about: [
|
|
4256
|
-
"Prints the account
|
|
5299
|
+
"Prints the account commands act in: its id, name and kind, your member subject and role in it, and its limits."
|
|
5300
|
+
],
|
|
5301
|
+
examples: ["tonbo account show --json", "tonbo account show --account acme"]
|
|
5302
|
+
},
|
|
5303
|
+
"tonbo account list": {
|
|
5304
|
+
about: [
|
|
5305
|
+
"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`.",
|
|
5306
|
+
"It needs the login of `tonbo login`: an API key in `TONBO_API_KEY` reaches only its own account, which `tonbo account show` prints."
|
|
4257
5307
|
],
|
|
4258
|
-
examples: ["tonbo account
|
|
5308
|
+
examples: ["tonbo account list", "tonbo account list --json"]
|
|
5309
|
+
},
|
|
5310
|
+
"tonbo account use": {
|
|
5311
|
+
about: [
|
|
5312
|
+
"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."
|
|
5313
|
+
],
|
|
5314
|
+
examples: ["tonbo account use acme"],
|
|
5315
|
+
arguments: { account: "an account you belong to, by name or id" }
|
|
4259
5316
|
},
|
|
4260
5317
|
"tonbo region list": {
|
|
4261
5318
|
about: [
|
|
@@ -4482,7 +5539,7 @@ var COMMAND_HELP = {
|
|
|
4482
5539
|
},
|
|
4483
5540
|
"tonbo machine create": {
|
|
4484
5541
|
about: [
|
|
4485
|
-
"Creates a Machine, a VM booted from an image in one region
|
|
5542
|
+
"Creates a Machine, a VM booted from an image in one region. It has no database of its own: `--database <name>` binds one of the account's databases of its region, which it then reaches at `TONBO_DATABASE_URL`, and a database binds one Machine at a time; its files can last on a Volume (`--volume`), and its program can open any other 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`.",
|
|
4486
5543
|
"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.",
|
|
4487
5544
|
"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."
|
|
4488
5545
|
],
|
|
@@ -4494,7 +5551,7 @@ var COMMAND_HELP = {
|
|
|
4494
5551
|
defaults: {
|
|
4495
5552
|
"--shape": "the shape `tonbo machine shapes` marks as the default",
|
|
4496
5553
|
"--default-port": "the first --port",
|
|
4497
|
-
"--region": "the --volume's region, else the account's default region",
|
|
5554
|
+
"--region": "the --volume's or --database's region, else the account's default region",
|
|
4498
5555
|
"--idempotency-key": "a new UUID",
|
|
4499
5556
|
"--timeout": String(DEFAULT_WAIT_SECONDS),
|
|
4500
5557
|
"--delete-after": "never"
|
|
@@ -4502,7 +5559,7 @@ var COMMAND_HELP = {
|
|
|
4502
5559
|
},
|
|
4503
5560
|
"tonbo machine show": {
|
|
4504
5561
|
about: [
|
|
4505
|
-
"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,
|
|
5562
|
+
"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>`.",
|
|
4506
5563
|
"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."
|
|
4507
5564
|
],
|
|
4508
5565
|
examples: ["tonbo machine show web"],
|
|
@@ -4534,7 +5591,7 @@ var COMMAND_HELP = {
|
|
|
4534
5591
|
},
|
|
4535
5592
|
"tonbo machine delete": {
|
|
4536
5593
|
about: [
|
|
4537
|
-
"Deletes the Machine with its disk
|
|
5594
|
+
"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.",
|
|
4538
5595
|
"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."
|
|
4539
5596
|
],
|
|
4540
5597
|
examples: ["tonbo machine delete web --yes"],
|
|
@@ -4648,19 +5705,19 @@ function optionDescription(option) {
|
|
|
4648
5705
|
function applyHelp(program) {
|
|
4649
5706
|
const named = /* @__PURE__ */ new Set();
|
|
4650
5707
|
const walk = (command, words) => {
|
|
4651
|
-
const
|
|
5708
|
+
const path4 = words.join(" ");
|
|
4652
5709
|
command.configureHelp({ ...command.configureHelp(), optionDescription });
|
|
4653
|
-
const help = COMMAND_HELP[
|
|
5710
|
+
const help = COMMAND_HELP[path4];
|
|
4654
5711
|
if (help) {
|
|
4655
|
-
named.add(
|
|
5712
|
+
named.add(path4);
|
|
4656
5713
|
for (const [name, text] of Object.entries(help.arguments ?? {})) {
|
|
4657
5714
|
const argument = command.registeredArguments.find((entry) => entry.name() === name);
|
|
4658
|
-
if (!argument) throw new Error(`${
|
|
5715
|
+
if (!argument) throw new Error(`${path4} has no argument ${name}`);
|
|
4659
5716
|
argument.description = text;
|
|
4660
5717
|
}
|
|
4661
5718
|
for (const [flag, text] of Object.entries(help.defaults ?? {})) {
|
|
4662
5719
|
const option = command.options.find((entry) => entry.long === flag);
|
|
4663
|
-
if (!option) throw new Error(`${
|
|
5720
|
+
if (!option) throw new Error(`${path4} has no option ${flag}`);
|
|
4664
5721
|
option.defaultValueDescription = text;
|
|
4665
5722
|
}
|
|
4666
5723
|
command.addHelpText("after", commandHelpText(help));
|
|
@@ -4668,8 +5725,8 @@ function applyHelp(program) {
|
|
|
4668
5725
|
for (const child of command.commands) walk(child, [...words, child.name()]);
|
|
4669
5726
|
};
|
|
4670
5727
|
walk(program, [program.name()]);
|
|
4671
|
-
for (const
|
|
4672
|
-
if (!named.has(
|
|
5728
|
+
for (const path4 of Object.keys(COMMAND_HELP))
|
|
5729
|
+
if (!named.has(path4)) throw new Error(`The help of ${path4} names no command`);
|
|
4673
5730
|
program.addHelpText("after", programHelpText());
|
|
4674
5731
|
return program;
|
|
4675
5732
|
}
|
|
@@ -4703,13 +5760,29 @@ async function terminalAsk(question) {
|
|
|
4703
5760
|
var packageVersion = JSON.parse(
|
|
4704
5761
|
readFileSync(new URL("../../package.json", import.meta.url), "utf8")
|
|
4705
5762
|
);
|
|
4706
|
-
function createDependencies() {
|
|
5763
|
+
function createDependencies(selection = {}) {
|
|
4707
5764
|
const api = apiOrigin();
|
|
4708
|
-
const
|
|
5765
|
+
const account = selection.account?.trim() || process.env.TONBO_ACCOUNT?.trim() || void 0;
|
|
5766
|
+
const credentials = new LoginStore({
|
|
5767
|
+
fetcher: fetch,
|
|
5768
|
+
apiOrigin: api,
|
|
5769
|
+
loginOrigin: loginOrigin(api),
|
|
5770
|
+
account
|
|
5771
|
+
});
|
|
4709
5772
|
return {
|
|
4710
5773
|
api: new ManagementApi(fetch, api),
|
|
4711
5774
|
ask: terminalAsk,
|
|
4712
|
-
auth: new AuthClient(
|
|
5775
|
+
auth: new AuthClient(
|
|
5776
|
+
credentials,
|
|
5777
|
+
fetch,
|
|
5778
|
+
loginOrigin(api),
|
|
5779
|
+
void 0,
|
|
5780
|
+
void 0,
|
|
5781
|
+
void 0,
|
|
5782
|
+
void 0,
|
|
5783
|
+
process.env,
|
|
5784
|
+
{ client: "tonbo", clientVersion: packageVersion.version }
|
|
5785
|
+
),
|
|
4713
5786
|
credentials,
|
|
4714
5787
|
interactive: () => process.stdin.isTTY === true && process.stderr.isTTY === true,
|
|
4715
5788
|
output: (text) => console.log(text),
|
|
@@ -4786,20 +5859,31 @@ function parseUsageSort(value) {
|
|
|
4786
5859
|
throw new InvalidArgumentError(error.message);
|
|
4787
5860
|
}
|
|
4788
5861
|
}
|
|
4789
|
-
function createProgram(
|
|
4790
|
-
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version)
|
|
4791
|
-
|
|
5862
|
+
function createProgram(makeDependencies = createDependencies) {
|
|
5863
|
+
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version).option(
|
|
5864
|
+
"--account <account>",
|
|
5865
|
+
"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"
|
|
5866
|
+
);
|
|
5867
|
+
const dependencies = () => makeDependencies({ account: program.opts().account });
|
|
5868
|
+
program.command("login").description(
|
|
5869
|
+
"sign this computer in through the browser; it can then act in every account you belong to"
|
|
5870
|
+
).option(
|
|
4792
5871
|
"--device",
|
|
4793
5872
|
"approve with a code in a browser on any computer, as over SSH without a display"
|
|
4794
5873
|
).action(async (options) => {
|
|
4795
|
-
|
|
5874
|
+
const account2 = program.opts().account;
|
|
5875
|
+
await loginCommand(dependencies(), { ...options, account: account2 });
|
|
4796
5876
|
});
|
|
4797
|
-
program.command("logout").description("
|
|
4798
|
-
const account = program.command("account").description("the
|
|
5877
|
+
program.command("logout").description("end this computer's CLI login and remove the saved login").action(async () => logoutCommand(dependencies()));
|
|
5878
|
+
const account = program.command("account").description("the accounts you belong to and the one commands act in");
|
|
4799
5879
|
operationOptions(
|
|
4800
5880
|
account.command("show").description("show the account, your role in it and its limits"),
|
|
4801
5881
|
"getAccount"
|
|
4802
5882
|
).action(async (options) => accountShowCommand(dependencies(), options));
|
|
5883
|
+
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));
|
|
5884
|
+
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(
|
|
5885
|
+
async (name, options) => accountUseCommand(dependencies(), name, options)
|
|
5886
|
+
);
|
|
4803
5887
|
operationOptions(
|
|
4804
5888
|
program.command("region").description("regions new resources can be created in").command("list").description("list the regions"),
|
|
4805
5889
|
"listRegions"
|
|
@@ -5042,7 +6126,10 @@ function createProgram(dependencies = createDependencies) {
|
|
|
5042
6126
|
).requiredOption(
|
|
5043
6127
|
"--image <reference>",
|
|
5044
6128
|
"the image the Machine boots: a public OCI image with a linux/arm64 variant (nginx:1.27, ghcr.io/org/app@sha256:...), or tonbo.dev/platform"
|
|
5045
|
-
).option("--region <region>", "the Machine's region, fixed for its life")
|
|
6129
|
+
).option("--region <region>", "the Machine's region, fixed for its life").option(
|
|
6130
|
+
"--database <name>",
|
|
6131
|
+
"bind the account's database <name> (see tonbo database list), of the Machine's region: its URL is TONBO_DATABASE_URL and the Machine writes it"
|
|
6132
|
+
).option("--no-database", "bind no database (the default)"),
|
|
5046
6133
|
false
|
|
5047
6134
|
).option(
|
|
5048
6135
|
"--idempotency-key <key>",
|
|
@@ -5076,9 +6163,7 @@ function createProgram(dependencies = createDependencies) {
|
|
|
5076
6163
|
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
5077
6164
|
);
|
|
5078
6165
|
operationOptions(
|
|
5079
|
-
machine.command("delete <name>").description(
|
|
5080
|
-
"delete a Machine with its disk, database and logs, or forget one of your computers"
|
|
5081
|
-
).option("--yes", "delete without asking; required when stdin is not a terminal").option("--if-match <etag>", ifMatchHelp("delete")),
|
|
6166
|
+
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")),
|
|
5082
6167
|
"deleteMachine"
|
|
5083
6168
|
).action(
|
|
5084
6169
|
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|