@tonbo/cli 0.11.0 → 0.12.0

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