@tonbo/cli 0.11.1 → 0.13.0

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