@tonbo/cli 0.14.1 → 0.16.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 +1 -1
  2. package/dist/bin/tonbo.js +1628 -159
  3. package/package.json +2 -2
package/dist/bin/tonbo.js CHANGED
@@ -174,6 +174,42 @@ var managementOperations = {
174
174
  "idempotencyKey": false,
175
175
  "cli": "tonbo account show"
176
176
  },
177
+ "listBudgets": {
178
+ "method": "GET",
179
+ "path": "/budgets",
180
+ "pathParams": [],
181
+ "query": [],
182
+ "idempotencyKey": false,
183
+ "cli": "tonbo budget list"
184
+ },
185
+ "createBudget": {
186
+ "method": "POST",
187
+ "path": "/budgets",
188
+ "pathParams": [],
189
+ "query": [],
190
+ "idempotencyKey": true,
191
+ "cli": "tonbo budget add"
192
+ },
193
+ "getBudget": {
194
+ "method": "GET",
195
+ "path": "/budgets/{budget_id}",
196
+ "pathParams": [
197
+ "budget_id"
198
+ ],
199
+ "query": [],
200
+ "idempotencyKey": false,
201
+ "cli": "tonbo budget show"
202
+ },
203
+ "changeBudget": {
204
+ "method": "PATCH",
205
+ "path": "/budgets/{budget_id}",
206
+ "pathParams": [
207
+ "budget_id"
208
+ ],
209
+ "query": [],
210
+ "idempotencyKey": false,
211
+ "cli": "tonbo budget set"
212
+ },
177
213
  "listRegions": {
178
214
  "method": "GET",
179
215
  "path": "/regions",
@@ -438,6 +474,13 @@ var managementOperations = {
438
474
  "query": [],
439
475
  "idempotencyKey": false
440
476
  },
477
+ "renewHandoffHolds": {
478
+ "method": "POST",
479
+ "path": "/yield/handoffs/holds",
480
+ "pathParams": [],
481
+ "query": [],
482
+ "idempotencyKey": false
483
+ },
441
484
  "showPersonalHandoff": {
442
485
  "method": "GET",
443
486
  "path": "/yield/handoffs/{id}",
@@ -574,6 +617,13 @@ var managementOperations = {
574
617
  "query": [],
575
618
  "idempotencyKey": false
576
619
  },
620
+ "leaveYieldOrganization": {
621
+ "method": "POST",
622
+ "path": "/yield/leave",
623
+ "pathParams": [],
624
+ "query": [],
625
+ "idempotencyKey": false
626
+ },
577
627
  "recordYieldMetric": {
578
628
  "method": "POST",
579
629
  "path": "/yield/metrics",
@@ -600,6 +650,13 @@ var managementOperations = {
600
650
  "query": [],
601
651
  "idempotencyKey": false
602
652
  },
653
+ "createYieldPickup": {
654
+ "method": "POST",
655
+ "path": "/yield/pickups",
656
+ "pathParams": [],
657
+ "query": [],
658
+ "idempotencyKey": false
659
+ },
603
660
  "listYieldQuestions": {
604
661
  "method": "GET",
605
662
  "path": "/yield/questions",
@@ -794,6 +851,16 @@ var managementOperations = {
794
851
  "idempotencyKey": false,
795
852
  "cli": "tonbo machine sleep"
796
853
  },
854
+ "wakeMachine": {
855
+ "method": "POST",
856
+ "path": "/machines/{name}/wake",
857
+ "pathParams": [
858
+ "name"
859
+ ],
860
+ "query": [],
861
+ "idempotencyKey": false,
862
+ "cli": "tonbo machine wake"
863
+ },
797
864
  "listMachineActivations": {
798
865
  "method": "GET",
799
866
  "path": "/machines/{name}/activations",
@@ -924,6 +991,101 @@ var managementOperations = {
924
991
  "cli": "tonbo machine file list",
925
992
  "cell": true
926
993
  },
994
+ "listMachineProcesses": {
995
+ "method": "GET",
996
+ "path": "/machines/{name}/processes",
997
+ "pathParams": [
998
+ "name"
999
+ ],
1000
+ "query": [],
1001
+ "idempotencyKey": false,
1002
+ "cli": "tonbo machine ps",
1003
+ "cell": true
1004
+ },
1005
+ "startMachineProcess": {
1006
+ "method": "POST",
1007
+ "path": "/machines/{name}/processes",
1008
+ "pathParams": [
1009
+ "name"
1010
+ ],
1011
+ "query": [],
1012
+ "idempotencyKey": true,
1013
+ "cli": "tonbo machine exec --detach",
1014
+ "cell": true
1015
+ },
1016
+ "getMachineProcess": {
1017
+ "method": "GET",
1018
+ "path": "/machines/{name}/processes/{process}",
1019
+ "pathParams": [
1020
+ "name",
1021
+ "process"
1022
+ ],
1023
+ "query": [],
1024
+ "idempotencyKey": false,
1025
+ "cli": "tonbo machine wait",
1026
+ "cell": true
1027
+ },
1028
+ "killMachineProcess": {
1029
+ "method": "DELETE",
1030
+ "path": "/machines/{name}/processes/{process}",
1031
+ "pathParams": [
1032
+ "name",
1033
+ "process"
1034
+ ],
1035
+ "query": [],
1036
+ "idempotencyKey": false,
1037
+ "cli": "tonbo machine kill",
1038
+ "cell": true
1039
+ },
1040
+ "readMachineProcessOutput": {
1041
+ "method": "GET",
1042
+ "path": "/machines/{name}/processes/{process}/output",
1043
+ "pathParams": [
1044
+ "name",
1045
+ "process"
1046
+ ],
1047
+ "query": [
1048
+ "offset"
1049
+ ],
1050
+ "idempotencyKey": false,
1051
+ "cli": "tonbo machine output",
1052
+ "cell": true
1053
+ },
1054
+ "listMachineHolds": {
1055
+ "method": "GET",
1056
+ "path": "/machines/{name}/holds",
1057
+ "pathParams": [
1058
+ "name"
1059
+ ],
1060
+ "query": [],
1061
+ "idempotencyKey": false,
1062
+ "cli": "tonbo machine hold ls",
1063
+ "cell": true
1064
+ },
1065
+ "setMachineHold": {
1066
+ "method": "PUT",
1067
+ "path": "/machines/{name}/holds/{hold}",
1068
+ "pathParams": [
1069
+ "name",
1070
+ "hold"
1071
+ ],
1072
+ "query": [],
1073
+ "idempotencyKey": false,
1074
+ "cli": "tonbo machine hold set",
1075
+ "cell": true
1076
+ },
1077
+ "releaseMachineHold": {
1078
+ "method": "DELETE",
1079
+ "path": "/machines/{name}/holds/{hold}",
1080
+ "pathParams": [
1081
+ "name",
1082
+ "hold"
1083
+ ],
1084
+ "query": [],
1085
+ "idempotencyKey": false,
1086
+ "cli": "tonbo machine hold rm",
1087
+ "cell": true
1088
+ },
927
1089
  "issueSshCertificate": {
928
1090
  "method": "POST",
929
1091
  "path": "/ssh/certificates",
@@ -1059,6 +1221,146 @@ var managementOperations = {
1059
1221
  "cli": "tonbo stream usage"
1060
1222
  }
1061
1223
  };
1224
+ var plannedManagementOperations = {
1225
+ "getMachineActivation": {
1226
+ "method": "GET",
1227
+ "path": "/machines/{name}/activations/{activation}",
1228
+ "pathParams": [
1229
+ "name",
1230
+ "activation"
1231
+ ],
1232
+ "query": [
1233
+ "cursor",
1234
+ "limit"
1235
+ ],
1236
+ "idempotencyKey": false,
1237
+ "cli": "tonbo machine activation show"
1238
+ },
1239
+ "getMachineUsage": {
1240
+ "method": "GET",
1241
+ "path": "/machines/{name}/usage",
1242
+ "pathParams": [
1243
+ "name"
1244
+ ],
1245
+ "query": [
1246
+ "from",
1247
+ "to",
1248
+ "cursor",
1249
+ "limit"
1250
+ ],
1251
+ "idempotencyKey": false,
1252
+ "cli": "tonbo machine usage"
1253
+ },
1254
+ "setMachineProcessDeadline": {
1255
+ "method": "PUT",
1256
+ "path": "/machines/{machine}/processes/{process}/deadline",
1257
+ "pathParams": [
1258
+ "machine",
1259
+ "process"
1260
+ ],
1261
+ "query": [],
1262
+ "idempotencyKey": false,
1263
+ "cli": "tonbo machine extend",
1264
+ "cell": true
1265
+ },
1266
+ "signalMachineProcess": {
1267
+ "method": "POST",
1268
+ "path": "/machines/{machine}/processes/{process}/signal",
1269
+ "pathParams": [
1270
+ "machine",
1271
+ "process"
1272
+ ],
1273
+ "query": [],
1274
+ "idempotencyKey": false,
1275
+ "cli": "tonbo machine signal",
1276
+ "cell": true
1277
+ },
1278
+ "createMachineDirectory": {
1279
+ "method": "PUT",
1280
+ "path": "/machines/{machine}/files/directory",
1281
+ "pathParams": [
1282
+ "machine"
1283
+ ],
1284
+ "query": [
1285
+ "path",
1286
+ "parents",
1287
+ "mode",
1288
+ "user"
1289
+ ],
1290
+ "idempotencyKey": true,
1291
+ "cli": "tonbo machine file mkdir",
1292
+ "cell": true
1293
+ },
1294
+ "renameMachineFile": {
1295
+ "method": "POST",
1296
+ "path": "/machines/{machine}/files/rename",
1297
+ "pathParams": [
1298
+ "machine"
1299
+ ],
1300
+ "query": [
1301
+ "user"
1302
+ ],
1303
+ "idempotencyKey": true,
1304
+ "cli": "tonbo machine file mv",
1305
+ "cell": true
1306
+ },
1307
+ "getMachineFileArchive": {
1308
+ "method": "GET",
1309
+ "path": "/machines/{machine}/files/archive",
1310
+ "pathParams": [
1311
+ "machine"
1312
+ ],
1313
+ "query": [
1314
+ "path",
1315
+ "format",
1316
+ "user"
1317
+ ],
1318
+ "idempotencyKey": false,
1319
+ "cli": "tonbo machine file archive",
1320
+ "cell": true
1321
+ },
1322
+ "readMachineLogs": {
1323
+ "method": "GET",
1324
+ "path": "/machines/{machine}/logs",
1325
+ "pathParams": [
1326
+ "machine"
1327
+ ],
1328
+ "query": [
1329
+ "activation",
1330
+ "cursor",
1331
+ "since",
1332
+ "limit",
1333
+ "follow"
1334
+ ],
1335
+ "idempotencyKey": false,
1336
+ "cli": "tonbo machine logs",
1337
+ "cell": true
1338
+ },
1339
+ "listSshCertificates": {
1340
+ "method": "GET",
1341
+ "path": "/ssh/certificates",
1342
+ "pathParams": [],
1343
+ "query": [
1344
+ "member",
1345
+ "cursor",
1346
+ "limit"
1347
+ ],
1348
+ "idempotencyKey": false,
1349
+ "cli": "tonbo ssh certificate list",
1350
+ "cell": true
1351
+ },
1352
+ "revokeSshCertificate": {
1353
+ "method": "DELETE",
1354
+ "path": "/ssh/certificates/{certificate}",
1355
+ "pathParams": [
1356
+ "certificate"
1357
+ ],
1358
+ "query": [],
1359
+ "idempotencyKey": false,
1360
+ "cli": "tonbo ssh certificate revoke",
1361
+ "cell": true
1362
+ }
1363
+ };
1062
1364
  var accountRoles = [
1063
1365
  "owner",
1064
1366
  "admin",
@@ -1673,6 +1975,7 @@ var machinePortAccess = [
1673
1975
  var machineTimings = {
1674
1976
  "pollAfterSeconds": 2,
1675
1977
  "sleepPollAfterSeconds": 5,
1978
+ "wakePollAfterSeconds": 2,
1676
1979
  "wakeHoldSeconds": 25,
1677
1980
  "earlyReclaimSeconds": 120
1678
1981
  };
@@ -1687,19 +1990,29 @@ var MANAGEMENT_AUDIENCE = cliSessionContract.audiences.management;
1687
1990
  function isAccountBearer(bearer) {
1688
1991
  return typeof bearer !== "string";
1689
1992
  }
1993
+ function retriesAfterRefusal(bearer, audience, code) {
1994
+ if (!isAccountBearer(bearer))
1995
+ return false;
1996
+ if (bearer.kind === "key")
1997
+ return bearer.refused(audience, code);
1998
+ bearer.invalidate(audience);
1999
+ return true;
2000
+ }
1690
2001
  function managementBearer(bearer) {
1691
2002
  return isAccountBearer(bearer) ? bearer.token(MANAGEMENT_AUDIENCE) : Promise.resolve(bearer);
1692
2003
  }
1693
2004
 
1694
2005
  // ../../packages/tonbo-cli-core/dist/src/access-token.js
1695
2006
  var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
2007
+ var STREAMS_AUDIENCE = "https://streams.tonbo.dev";
1696
2008
  var GIT_AUDIENCE = "https://git.tonbo.dev";
2009
+ var REFRESH_MARGIN_MS = 6e4;
1697
2010
  var AccessTokenError = class extends Error {
1698
2011
  status;
1699
2012
  retryAfterMs;
1700
2013
  code;
1701
- constructor(message, status, retryAfterMs = null, code = null) {
1702
- super(message);
2014
+ constructor(message, status, retryAfterMs = null, code = null, options) {
2015
+ super(message, options);
1703
2016
  this.status = status;
1704
2017
  this.retryAfterMs = retryAfterMs;
1705
2018
  this.code = code;
@@ -1708,6 +2021,116 @@ var AccessTokenError = class extends Error {
1708
2021
  return this.status === null || this.status === 429 || this.status >= 500;
1709
2022
  }
1710
2023
  };
2024
+ var AccessTokens = class {
2025
+ fetcher;
2026
+ userOrigin;
2027
+ audience;
2028
+ loginHint;
2029
+ now;
2030
+ cached = null;
2031
+ /** The account bearer the last token was of, which `invalidate` forgets. */
2032
+ last = null;
2033
+ constructor(fetcher, userOrigin, audience = STREAMS_AUDIENCE, loginHint = DEFAULT_LOGIN_HINT, now = () => Date.now()) {
2034
+ this.fetcher = fetcher;
2035
+ this.userOrigin = userOrigin;
2036
+ this.audience = audience;
2037
+ this.loginHint = loginHint;
2038
+ this.now = now;
2039
+ }
2040
+ async token(key) {
2041
+ if (isAccountBearer(key)) {
2042
+ this.last = key;
2043
+ return key.token(this.audience);
2044
+ }
2045
+ if (this.cached && this.cached.key === key && this.cached.refreshAt > this.now())
2046
+ return this.cached.token;
2047
+ const issuedAt = this.now();
2048
+ const what = this.audience === GIT_AUDIENCE ? "git" : this.audience === MANAGEMENT_AUDIENCE ? "Management" : "Streams";
2049
+ let response;
2050
+ try {
2051
+ response = await this.fetcher(`${this.userOrigin}/api/iam/token`, {
2052
+ method: "POST",
2053
+ redirect: "manual",
2054
+ signal: AbortSignal.timeout(3e4),
2055
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
2056
+ body: JSON.stringify({ audience: this.audience })
2057
+ });
2058
+ } catch (error) {
2059
+ throw new AccessTokenError(`The ${what} token exchange failed: ${error instanceof Error ? error.message : String(error)}`, null, null, null, { cause: error });
2060
+ }
2061
+ if (!response.ok) {
2062
+ const problem = await response.json().catch(() => null);
2063
+ const retryAfter = Number(response.headers.get("retry-after") ?? "");
2064
+ throw new AccessTokenError(response.status === 401 ? `Your Tonbo login was refused. Run \`${this.loginHint}\`.` : `The ${what} token exchange answered ${response.status}.`, response.status, Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1e3 : null, typeof problem?.code === "string" ? problem.code : null);
2065
+ }
2066
+ const body = await response.json().catch(() => null);
2067
+ if (typeof body?.access_token !== "string" || !body.access_token || typeof body.expires_in !== "number" || !(body.expires_in > 0))
2068
+ throw new AccessTokenError(`The ${what} token exchange answered no token.`, response.status);
2069
+ this.cached = {
2070
+ key,
2071
+ token: body.access_token,
2072
+ refreshAt: issuedAt + Math.max(0, body.expires_in * 1e3 - REFRESH_MARGIN_MS)
2073
+ };
2074
+ return body.access_token;
2075
+ }
2076
+ /** Forgets the token, after the resource server refused it. */
2077
+ invalidate() {
2078
+ this.cached = null;
2079
+ this.last?.invalidate(this.audience);
2080
+ }
2081
+ /** Sends a request with the token; a 401 drops the token and sends once
2082
+ * more with a newly exchanged one, whose answer is final. */
2083
+ async send(key, request) {
2084
+ const response = await request(await this.token(key));
2085
+ if (response.status !== 401)
2086
+ return response;
2087
+ await response.body?.cancel();
2088
+ if (isAccountBearer(key))
2089
+ key.invalidate(this.audience);
2090
+ else
2091
+ this.invalidate();
2092
+ return request(await this.token(key));
2093
+ }
2094
+ };
2095
+ function apiKeyBearer(key, fetcher, userOrigin, now = () => Date.now()) {
2096
+ const tokens = /* @__PURE__ */ new Map();
2097
+ let exchanging = false;
2098
+ const tokensOf = (audience) => {
2099
+ let found = tokens.get(audience);
2100
+ if (!found) {
2101
+ found = new AccessTokens(fetcher, userOrigin, audience, void 0, now);
2102
+ tokens.set(audience, found);
2103
+ }
2104
+ return found;
2105
+ };
2106
+ return {
2107
+ kind: "key",
2108
+ async token(audience) {
2109
+ if (audience === MANAGEMENT_AUDIENCE && !exchanging)
2110
+ return key;
2111
+ try {
2112
+ return await tokensOf(audience).token(key);
2113
+ } catch (error) {
2114
+ if (!(error instanceof AccessTokenError) || error.status !== 401)
2115
+ throw error;
2116
+ throw new AccessTokenError("TONBO_API_KEY was refused (HTTP 401). Check that the key exists and is not revoked or expired.", 401, null, error.code);
2117
+ }
2118
+ },
2119
+ invalidate(audience) {
2120
+ tokens.get(audience)?.invalidate();
2121
+ },
2122
+ refused(audience, code) {
2123
+ if (audience === MANAGEMENT_AUDIENCE && !exchanging) {
2124
+ if (code !== "exchange_required")
2125
+ return false;
2126
+ exchanging = true;
2127
+ return true;
2128
+ }
2129
+ tokens.get(audience)?.invalidate();
2130
+ return true;
2131
+ }
2132
+ };
2133
+ }
1711
2134
 
1712
2135
  // ../../packages/tonbo-cli-core/dist/src/http.js
1713
2136
  var HttpError = class extends Error {
@@ -1733,11 +2156,11 @@ async function requestJson(fetcher, url, init = {}) {
1733
2156
  if (response.ok)
1734
2157
  return body;
1735
2158
  const record = body && typeof body === "object" ? body : {};
1736
- const reason = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
2159
+ const reason2 = [record.detail, record.title, record.error].find((value) => typeof value === "string" && value.length > 0);
1737
2160
  const requestId = response.headers.get("x-request-id") ?? (typeof record.request_id === "string" ? record.request_id : null);
1738
2161
  const issues = Array.isArray(record.errors) ? record.errors.filter((issue) => typeof issue?.field === "string" && typeof issue.code === "string").map((issue) => `${issue.field}: ${issue.code}`) : [];
1739
2162
  const message = [
1740
- reason ?? `Request failed with HTTP ${response.status}.`,
2163
+ reason2 ?? `Request failed with HTTP ${response.status}.`,
1741
2164
  ...issues.length ? [`(${issues.join(", ")})`] : []
1742
2165
  ].join(" ");
1743
2166
  throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
@@ -1745,6 +2168,9 @@ async function requestJson(fetcher, url, init = {}) {
1745
2168
 
1746
2169
  // ../../packages/tonbo-cli-core/dist/src/api.js
1747
2170
  var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
2171
+ function managementOperation(operationId) {
2172
+ return operationId in managementOperations ? managementOperations[operationId] : plannedManagementOperations[operationId];
2173
+ }
1748
2174
  var DEFAULT_LOGIN_HINT2 = credentialsContract.reader.login_hint;
1749
2175
  var ExchangeRefusal = class extends HttpError {
1750
2176
  };
@@ -1752,9 +2178,9 @@ async function presented(key) {
1752
2178
  try {
1753
2179
  return await managementBearer(key);
1754
2180
  } catch (error) {
1755
- if (!(error instanceof AccessTokenError))
2181
+ if (!(error instanceof AccessTokenError) || error.status === null)
1756
2182
  throw error;
1757
- throw new ExchangeRefusal(error.message, error.status ?? 503, { code: error.code }, null);
2183
+ throw new ExchangeRefusal(error.message, error.status, { code: error.code }, null);
1758
2184
  }
1759
2185
  }
1760
2186
  var ManagementApi = class {
@@ -1782,15 +2208,14 @@ var ManagementApi = class {
1782
2208
  try {
1783
2209
  return await attempt();
1784
2210
  } catch (error) {
1785
- if (!isAccountBearer(key) || !(error instanceof HttpError) || error.status !== 401 || error instanceof ExchangeRefusal)
2211
+ if (!(error instanceof HttpError) || error.status !== 401 || error instanceof ExchangeRefusal || !retriesAfterRefusal(key, MANAGEMENT_AUDIENCE, error.code))
1786
2212
  throw error;
1787
- key.invalidate(MANAGEMENT_AUDIENCE);
1788
2213
  return attempt();
1789
2214
  }
1790
2215
  }
1791
2216
  async requestWith(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }, seen) {
1792
- const operation = managementOperations[operationId];
1793
- const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
2217
+ const operation = managementOperation(operationId);
2218
+ const path5 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
1794
2219
  const value = params[name];
1795
2220
  if (value === void 0)
1796
2221
  throw new Error(`${operationId} needs the path parameter ${name}.`);
@@ -1814,7 +2239,7 @@ var ManagementApi = class {
1814
2239
  if (ifMatch)
1815
2240
  headers["if-match"] = ifMatch;
1816
2241
  const queryString = search.toString();
1817
- const url = `${this.apiOrigin}${managementApiBasePath}${path4}${queryString ? `?${queryString}` : ""}`;
2242
+ const url = `${this.apiOrigin}${managementApiBasePath}${path5}${queryString ? `?${queryString}` : ""}`;
1818
2243
  try {
1819
2244
  const fetcher = seen ? async (input, init) => {
1820
2245
  const response = await this.fetcher(input, init);
@@ -1855,6 +2280,27 @@ var ManagementApi = class {
1855
2280
  idempotencyKey
1856
2281
  }).catch(unconfirmed("Agent creation", idempotencyKey, "the same name, role and region"));
1857
2282
  }
2283
+ /** The account's budgets (planned: 404 until budgets are served). */
2284
+ listBudgets(key) {
2285
+ return this.call("listBudgets", key);
2286
+ }
2287
+ getBudget(key, budgetId) {
2288
+ return this.call("getBudget", key, { params: { budget_id: budgetId } });
2289
+ }
2290
+ /** A replay with the same idempotency key returns the budget the first
2291
+ * request created. */
2292
+ createBudget(key, request, idempotencyKey) {
2293
+ return this.call("createBudget", key, {
2294
+ body: withoutUndefined(request),
2295
+ idempotencyKey
2296
+ }).catch(unconfirmed("Budget creation", idempotencyKey, "the same name, limit and members"));
2297
+ }
2298
+ changeBudget(key, budgetId, change) {
2299
+ return this.call("changeBudget", key, {
2300
+ params: { budget_id: budgetId },
2301
+ body: withoutUndefined(change)
2302
+ });
2303
+ }
1858
2304
  listApiKeys(key, page) {
1859
2305
  return this.page("listApiKeys", key, page);
1860
2306
  }
@@ -2074,6 +2520,18 @@ var ManagementApi = class {
2074
2520
  });
2075
2521
  return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
2076
2522
  }
2523
+ /** With `etag`, only the Machine that ETag names, at that revision, is
2524
+ * woken (machine-v2.json `etag.if_match.wake` is optional). A 202 carries
2525
+ * the `Retry-After` to read the Machine again after (machine-v2.json
2526
+ * `wake.poll_after_seconds`); a 200 (already awake) carries none. A
2527
+ * refused wake throws the API's problem. */
2528
+ async wakeMachine(key, name, etag) {
2529
+ const headers = { retryAfter: null };
2530
+ const answer = await this.request("wakeMachine", key, { params: { name }, ...etag ? { ifMatch: etag } : {} }, (response) => {
2531
+ headers.retryAfter = response.headers.get("retry-after");
2532
+ });
2533
+ return { machine: answer.data, retryAfterSeconds: retryAfterSeconds(headers.retryAfter) };
2534
+ }
2077
2535
  /** The Machine being deleted or the device forgotten, with its kind; the
2078
2536
  * Machine is null when the API answered 204, whose `X-Tonbo-Machine-Kind`
2079
2537
  * says whether a name was released (`managed`: its create never reached its
@@ -2144,11 +2602,42 @@ import os4 from "node:os";
2144
2602
  import { promisify as promisify2 } from "node:util";
2145
2603
 
2146
2604
  // ../../packages/tonbo-cli-core/dist/src/callback-page.js
2147
- var WORDMARK = [
2148
- "\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",
2149
- "\u2588\u2588\u2584 \u2584\u2588\u2588\u2580\u2584 \u2588\u2588 \u2584\u2580\u2588\u2588 \u2584 \u2588\u2588 \u2584\u2580\u2588",
2150
- "\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"
2151
- ].join("\n");
2605
+ var LOGO_PIXELS = [
2606
+ [0, 5],
2607
+ [5, 0],
2608
+ [5, 5],
2609
+ [5, 10],
2610
+ [5, 15],
2611
+ [10, 5],
2612
+ [10, 15],
2613
+ [21, 10],
2614
+ [21, 15],
2615
+ [26, 5],
2616
+ [26, 15],
2617
+ [31, 5],
2618
+ [31, 10],
2619
+ [42, 5],
2620
+ [42, 10],
2621
+ [42, 15],
2622
+ [47, 5],
2623
+ [52, 10],
2624
+ [52, 15],
2625
+ [63, 0],
2626
+ [63, 5],
2627
+ [63, 10],
2628
+ [63, 15],
2629
+ [68, 5],
2630
+ [68, 15],
2631
+ [73, 5],
2632
+ [73, 10],
2633
+ [84, 5],
2634
+ [84, 10],
2635
+ [89, 5],
2636
+ [89, 15],
2637
+ [94, 10],
2638
+ [94, 15]
2639
+ ];
2640
+ var LOGO = `<svg class="wordmark" aria-hidden="true" viewBox="0 0 99 20" width="99" height="20"><g fill="currentColor" shape-rendering="crispEdges">${LOGO_PIXELS.map(([x, y]) => `<rect x="${x}" y="${y}" width="5" height="5"/>`).join("")}</g></svg>`;
2152
2641
  var OUTCOMES = {
2153
2642
  complete: {
2154
2643
  status: 200,
@@ -2185,7 +2674,7 @@ function renderCallbackPage(outcome) {
2185
2674
  --ink: #1d1b17;
2186
2675
  --rule: #c9c5ba;
2187
2676
  --quiet: #5c564a;
2188
- --signal: #d64a03;
2677
+ --signal: #e5673e;
2189
2678
  }
2190
2679
  @media (prefers-color-scheme: dark) {
2191
2680
  :root {
@@ -2193,7 +2682,7 @@ function renderCallbackPage(outcome) {
2193
2682
  --ink: #f3f2ee;
2194
2683
  --rule: #3a362e;
2195
2684
  --quiet: #a49e8f;
2196
- --signal: #f08b4b;
2685
+ --signal: #e5673e;
2197
2686
  }
2198
2687
  }
2199
2688
  * { box-sizing: border-box; }
@@ -2209,21 +2698,9 @@ function renderCallbackPage(outcome) {
2209
2698
  -webkit-font-smoothing: antialiased;
2210
2699
  }
2211
2700
  header { padding: 28px 0; }
2212
- /* Same metrics as .auth-wordmark in the app: the glyphs are the ground
2213
- and the letters are the gaps, so the 8px/10px/scaleY(0.8) combination is
2214
- what makes the rows meet without seams. Iosevka is not available here,
2215
- so it falls back to the platform monospace. */
2216
- pre.wordmark {
2217
- margin: 0;
2218
- font-family: 'Iosevka', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;
2219
- font-size: 8px;
2220
- font-weight: 400;
2221
- line-height: 10px;
2222
- letter-spacing: 0;
2223
- white-space: pre;
2701
+ svg.wordmark {
2702
+ display: block;
2224
2703
  color: var(--signal);
2225
- transform: scaleY(0.8);
2226
- transform-origin: left top;
2227
2704
  }
2228
2705
  main {
2229
2706
  display: flex;
@@ -2254,7 +2731,7 @@ function renderCallbackPage(outcome) {
2254
2731
  </style>
2255
2732
  </head>
2256
2733
  <body>
2257
- <header><pre class="wordmark" aria-hidden="true">${WORDMARK}</pre></header>
2734
+ <header>${LOGO}</header>
2258
2735
  <main>
2259
2736
  <h1>${title}</h1>
2260
2737
  <p>${body}</p>
@@ -2430,17 +2907,20 @@ function accountLogins(login) {
2430
2907
  });
2431
2908
  return entries;
2432
2909
  }
2910
+ function personalLogin(login) {
2911
+ return accountLogins(login).find((entry) => entry.kind === "user") ?? null;
2912
+ }
2433
2913
  async function preparePrivateDirectory(directory) {
2434
- const stat = await lstat(directory);
2435
- if (stat.isSymbolicLink() || !stat.isDirectory())
2914
+ const stat2 = await lstat(directory);
2915
+ if (stat2.isSymbolicLink() || !stat2.isDirectory())
2436
2916
  throw new Error("Tonbo config directory must be a real directory.");
2437
2917
  if (process.platform !== "win32")
2438
2918
  await chmod(directory, DIRECTORY_MODE);
2439
2919
  }
2440
2920
  async function assertExistingDestinationIsSafe(filename) {
2441
2921
  try {
2442
- const stat = await lstat(filename);
2443
- if (stat.isSymbolicLink() || !stat.isFile())
2922
+ const stat2 = await lstat(filename);
2923
+ if (stat2.isSymbolicLink() || !stat2.isFile())
2444
2924
  throw new Error("Tonbo credential path must be a regular file.");
2445
2925
  } catch (error) {
2446
2926
  if (error.code !== "ENOENT")
@@ -2448,17 +2928,17 @@ async function assertExistingDestinationIsSafe(filename) {
2448
2928
  }
2449
2929
  }
2450
2930
  async function assertPrivateRegularFile(filename) {
2451
- const stat = await lstat(filename);
2452
- if (stat.isSymbolicLink() || !stat.isFile())
2931
+ const stat2 = await lstat(filename);
2932
+ if (stat2.isSymbolicLink() || !stat2.isFile())
2453
2933
  throw new Error("Tonbo credential path must be a regular file.");
2454
- if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
2934
+ if (process.platform !== "win32" && (stat2.mode & REFUSED_MODE_BITS) !== 0)
2455
2935
  throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
2456
2936
  }
2457
2937
 
2458
2938
  // ../../packages/tonbo-cli-core/dist/src/device.js
2459
2939
  import { execFile } from "node:child_process";
2460
2940
  import { createHash, randomUUID as randomUUID2 } from "node:crypto";
2461
- import { link, mkdir as mkdir2, open as open2, readFile as readFile2, rename as rename2, rm as rm2 } from "node:fs/promises";
2941
+ import { link, mkdir as mkdir2, open as open2, readFile as readFile2, rename as rename2, rm as rm2, stat } from "node:fs/promises";
2462
2942
  import os2 from "node:os";
2463
2943
  import path2 from "node:path";
2464
2944
  import { promisify } from "node:util";
@@ -2485,6 +2965,14 @@ async function readMachineId(platform = process.platform) {
2485
2965
  function machineIdHash(machineId) {
2486
2966
  return createHash("sha256").update(machineId).digest("hex");
2487
2967
  }
2968
+ var NO_HARD_LINKS = /* @__PURE__ */ new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EMLINK", "EXDEV"]);
2969
+ var CREATE_POLL_MS = 50;
2970
+ var CREATE_STALE_MS = 5e3;
2971
+ var exists = (file) => stat(file).then(() => true, (error) => {
2972
+ if (error.code === "ENOENT")
2973
+ return false;
2974
+ throw error;
2975
+ });
2488
2976
  var DeviceStore = class {
2489
2977
  filename;
2490
2978
  machineId;
@@ -2562,13 +3050,49 @@ var DeviceStore = class {
2562
3050
  await link(temporary, this.filename);
2563
3051
  return true;
2564
3052
  } catch (error) {
2565
- if (!replace && error.code === "EEXIST")
3053
+ const code = error.code;
3054
+ if (!replace && code === "EEXIST")
2566
3055
  return false;
3056
+ if (!replace && code && NO_HARD_LINKS.has(code))
3057
+ return await this.create(temporary);
2567
3058
  throw error;
2568
3059
  } finally {
2569
3060
  await rm2(temporary, { force: true });
2570
3061
  }
2571
3062
  }
3063
+ /** The first record on a filesystem without hard links (some FUSE and
3064
+ * network volumes), from the complete, synced `temporary`: renamed into
3065
+ * place, so the file is never seen empty or partial, while holding a lock
3066
+ * directory made exclusively, so of two first runs at once only one
3067
+ * writes it and the other waits for it. A lock left by a run that died
3068
+ * holding it is taken over after a while. */
3069
+ async create(temporary) {
3070
+ const lock = `${this.filename}.lock`;
3071
+ for (let waited = 0; ; waited += CREATE_POLL_MS) {
3072
+ try {
3073
+ await mkdir2(lock, { mode: 448 });
3074
+ break;
3075
+ } catch (error) {
3076
+ if (error.code !== "EEXIST")
3077
+ throw error;
3078
+ }
3079
+ if (await exists(this.filename))
3080
+ return false;
3081
+ if (waited >= CREATE_STALE_MS) {
3082
+ await rm2(lock, { recursive: true, force: true });
3083
+ waited = -CREATE_POLL_MS;
3084
+ } else
3085
+ await new Promise((resolve) => setTimeout(resolve, CREATE_POLL_MS));
3086
+ }
3087
+ try {
3088
+ if (await exists(this.filename))
3089
+ return false;
3090
+ await rename2(temporary, this.filename);
3091
+ return true;
3092
+ } finally {
3093
+ await rm2(lock, { recursive: true, force: true });
3094
+ }
3095
+ }
2572
3096
  };
2573
3097
 
2574
3098
  // ../../packages/tonbo-cli-core/dist/src/session.js
@@ -2613,7 +3137,7 @@ var FileSessionStore = class {
2613
3137
  await removePrivateFile(this.filename);
2614
3138
  }
2615
3139
  };
2616
- var REFRESH_MARGIN_MS = 6e4;
3140
+ var REFRESH_MARGIN_MS2 = 6e4;
2617
3141
  var clients = /* @__PURE__ */ new WeakMap();
2618
3142
  var SessionClient = class _SessionClient {
2619
3143
  stored;
@@ -2663,6 +3187,14 @@ var SessionClient = class _SessionClient {
2663
3187
  }
2664
3188
  return bearer;
2665
3189
  }
3190
+ /** Drops every token of `accountId` this process keeps, and its bearer. */
3191
+ forget(accountId) {
3192
+ for (const id of [...this.tokens.keys()])
3193
+ if (id.endsWith(` ${accountId}`))
3194
+ this.tokens.delete(id);
3195
+ this.bearers.delete(accountId);
3196
+ this.workspaces.delete(accountId);
3197
+ }
2666
3198
  /** A token of `accountId` for `audience`, kept until a minute before it expires. */
2667
3199
  async token(audience, accountId) {
2668
3200
  const id = `${audience} ${accountId}`;
@@ -2700,7 +3232,7 @@ var SessionClient = class _SessionClient {
2700
3232
  }, what, accountId);
2701
3233
  this.tokens.set(`${audience} ${accountId}`, {
2702
3234
  token: body.access_token,
2703
- refreshAt: issuedAt + Math.max(0, body.expires_in * 1e3 - REFRESH_MARGIN_MS)
3235
+ refreshAt: issuedAt + Math.max(0, body.expires_in * 1e3 - REFRESH_MARGIN_MS2)
2704
3236
  });
2705
3237
  return body.access_token;
2706
3238
  }
@@ -2718,7 +3250,7 @@ var SessionClient = class _SessionClient {
2718
3250
  body: JSON.stringify(fields2)
2719
3251
  });
2720
3252
  } catch (error) {
2721
- throw new AccessTokenError(`The ${what} token exchange failed: ${error instanceof Error ? error.message : String(error)}`, null);
3253
+ throw new AccessTokenError(`The ${what} token exchange failed: ${error instanceof Error ? error.message : String(error)}`, null, null, null, { cause: error });
2722
3254
  }
2723
3255
  if (!response.ok)
2724
3256
  throw await this.refusal(response, what, accountId);
@@ -2759,7 +3291,7 @@ var SessionClient = class _SessionClient {
2759
3291
  headers: { authorization: `Bearer ${token}`, accept: "application/json" }
2760
3292
  });
2761
3293
  } catch (error) {
2762
- throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces failed: ${error instanceof Error ? error.message : String(error)}`, null);
3294
+ throw new AccessTokenError(`Reading ${this.accountName(accountId)}'s git workspaces failed: ${error instanceof Error ? error.message : String(error)}`, null, null, null, { cause: error });
2763
3295
  }
2764
3296
  const body = await response.json().catch(() => null);
2765
3297
  if (!response.ok)
@@ -2784,7 +3316,7 @@ var SessionClient = class _SessionClient {
2784
3316
  }
2785
3317
  });
2786
3318
  } catch (error) {
2787
- throw new AccessTokenError(`Reading your accounts failed: ${error instanceof Error ? error.message : String(error)}`, null);
3319
+ throw new AccessTokenError(`Reading your accounts failed: ${error instanceof Error ? error.message : String(error)}`, null, null, null, { cause: error });
2788
3320
  }
2789
3321
  if (!response.ok)
2790
3322
  throw await this.refusal(response, "session", "");
@@ -2866,17 +3398,46 @@ var LoginStore = class {
2866
3398
  await this.sessions.remove();
2867
3399
  await this.legacy.remove();
2868
3400
  }
2869
- /** The person's accounts now, saved as the login's cache of names. */
3401
+ /** The person's accounts now, saved as the login's cache of names. `client`
3402
+ * holds them before the save, so when the file cannot be written (an
3403
+ * agent's sandbox) `current(client)` still answers with them. */
2870
3404
  async refreshAccounts(client) {
2871
3405
  const accounts = await client.accounts();
2872
3406
  const latest = await this.sessions.load() ?? client.stored;
2873
3407
  if (latest.session.id === client.stored.session.id) {
2874
3408
  const saved = withAccounts(latest, accounts);
2875
- await this.sessions.save(saved);
2876
3409
  client.stored = saved;
3410
+ await this.sessions.save(saved);
2877
3411
  }
2878
3412
  return accounts;
2879
3413
  }
3414
+ /** The logins `client` holds in this process, without reading the file again. */
3415
+ current(client) {
3416
+ return this.view(client);
3417
+ }
3418
+ /** Forgets an account the person no longer belongs to (they left it, or
3419
+ * were removed): the login's cache of names is read again without it, it
3420
+ * is no longer the default account, and this process's tokens of it are
3421
+ * dropped. The CLI login itself stays: it still reaches every other
3422
+ * account. Without a CLI login there is nothing to forget. */
3423
+ async forgetAccount(accountId) {
3424
+ const client = await this.session().catch(() => null);
3425
+ if (!client)
3426
+ return;
3427
+ await this.refreshAccounts(client).catch(() => void 0);
3428
+ const latest = await this.sessions.load();
3429
+ if (!latest || latest.session.id !== client.stored.session.id)
3430
+ return;
3431
+ const { [accountId]: _forgotten, ...accounts } = latest.accounts ?? {};
3432
+ const saved = {
3433
+ ...latest,
3434
+ accounts,
3435
+ default_account_id: latest.default_account_id === accountId ? null : latest.default_account_id ?? null
3436
+ };
3437
+ await this.sessions.save(saved);
3438
+ client.stored = saved;
3439
+ client.forget(accountId);
3440
+ }
2880
3441
  /** The id of the account `nameOrId` names, looked up again once when the cache misses. */
2881
3442
  async resolve(client, nameOrId) {
2882
3443
  if (UUID2.test(nameOrId))
@@ -2934,11 +3495,21 @@ var LoginStore = class {
2934
3495
  const named = this.options.account;
2935
3496
  if (!named)
2936
3497
  return;
2937
- const response = await this.fetcher(`${this.options.apiOrigin}${managementApiBasePath}${managementOperations.getAccount.path}`, {
3498
+ const read = async () => this.fetcher(`${this.options.apiOrigin}${managementApiBasePath}${managementOperations.getAccount.path}`, {
2938
3499
  redirect: "manual",
2939
3500
  signal: AbortSignal.timeout(3e4),
2940
- headers: { authorization: `Bearer ${key}`, accept: "application/json" }
3501
+ headers: {
3502
+ authorization: `Bearer ${await managementBearer(key)}`,
3503
+ accept: "application/json"
3504
+ }
2941
3505
  });
3506
+ let response = await read();
3507
+ if (response.status === 401) {
3508
+ const refusal = await response.json().catch(() => null);
3509
+ const code = typeof refusal?.code === "string" ? refusal.code : null;
3510
+ if (retriesAfterRefusal(key, MANAGEMENT_AUDIENCE, code))
3511
+ response = await read();
3512
+ }
2942
3513
  const body = await response.json().catch(() => null);
2943
3514
  if (!response.ok)
2944
3515
  throw new Error(`TONBO_API_KEY was refused (HTTP ${response.status}).`);
@@ -2971,9 +3542,11 @@ var DEFAULT_LOGIN_HINT3 = credentialsContract.reader.login_hint;
2971
3542
  var LOOPBACK_HOST = "127.0.0.1";
2972
3543
  var BrowserUnavailableError = class extends Error {
2973
3544
  };
2974
- function prefersDeviceLogin(env = process.env) {
3545
+ function prefersDeviceLogin(env = process.env, platform = process.platform) {
3546
+ if (env.DISPLAY || env.WAYLAND_DISPLAY)
3547
+ return false;
2975
3548
  const ssh = Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
2976
- return ssh && !env.DISPLAY && !env.WAYLAND_DISPLAY;
3549
+ return ssh || platform !== "darwin" && platform !== "win32";
2977
3550
  }
2978
3551
  function loginHostname(hostname = os4.hostname()) {
2979
3552
  const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
@@ -2992,7 +3565,8 @@ var AuthClient = class {
2992
3565
  entryLine;
2993
3566
  env;
2994
3567
  facts;
2995
- keyChecked = null;
3568
+ /** TONBO_API_KEY's bearer, made once per key so its tokens and its exchange outlive a call. */
3569
+ injectedBearer = null;
2996
3570
  constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT3, entryLine = "cli", env = process.env, facts = {}) {
2997
3571
  this.credentials = credentials;
2998
3572
  this.fetcher = fetcher;
@@ -3006,18 +3580,25 @@ var AuthClient = class {
3006
3580
  }
3007
3581
  /**
3008
3582
  * The credential of the account a command acts in: TONBO_API_KEY (whose
3009
- * own account any account the command named must be), or the CLI login's
3010
- * bearer of the named account, else of its default, else of the personal
3011
- * account; with only a legacy login, that file's key.
3583
+ * own account any account the command named must be), presented as the
3584
+ * Tonbo SDK presents a key (apiKeyBearer), or the CLI login's bearer of the
3585
+ * named account, else of its default, else of the personal account; with
3586
+ * only a legacy login, that file's key.
3012
3587
  */
3013
3588
  async apiKey() {
3014
3589
  const injected = this.env.TONBO_API_KEY?.trim();
3015
3590
  if (injected) {
3016
- if (this.keyChecked !== injected) {
3017
- await this.credentials.checkKeyAccount(injected);
3018
- this.keyChecked = injected;
3591
+ if (this.injectedBearer?.key !== injected)
3592
+ this.injectedBearer = {
3593
+ key: injected,
3594
+ bearer: apiKeyBearer(injected, this.fetcher, this.loginOrigin),
3595
+ checked: false
3596
+ };
3597
+ if (!this.injectedBearer.checked) {
3598
+ await this.credentials.checkKeyAccount(this.injectedBearer.bearer);
3599
+ this.injectedBearer.checked = true;
3019
3600
  }
3020
- return injected;
3601
+ return this.injectedBearer.bearer;
3021
3602
  }
3022
3603
  const login = await this.credentials.load();
3023
3604
  if (!login)
@@ -3030,17 +3611,20 @@ var AuthClient = class {
3030
3611
  * Logs this computer in (ADR-0018): the browser handoff of /cli/login, PKCE
3031
3612
  * S256 with a loopback redirect on a free port, asks for a session
3032
3613
  * (`grant=cli_session`); the approval names this computer and asks no
3033
- * account. With `device`, in an SSH session without a display, or when no
3614
+ * account. With `device`, in an SSH session or on Linux without a display, or when no
3034
3615
  * browser can be opened, the same PKCE challenge goes to a device-code
3035
3616
  * login instead: the person enters a short code at /cli/device in any
3036
3617
  * browser and the CLI polls. The redeemed session, tied to this
3037
3618
  * computer's device id, is saved as `login.json` and replaces this
3038
3619
  * computer's earlier session; the legacy `credentials.json` is left as it
3039
3620
  * is. With `account` (a name or id), commands act there by default unless
3040
- * `current` is false. Answers the login of the account commands act in.
3621
+ * `current` is false. With `openBrowser` false the browser login opens no
3622
+ * browser: the person opens the `authorization-url` they are shown, as
3623
+ * when an agent shows it with what the approval consents to. Answers the
3624
+ * login of the account commands act in.
3041
3625
  */
3042
3626
  async login(progress = () => {
3043
- }, account, { current = true, device } = {}) {
3627
+ }, account, { current = true, device, openBrowser: openBrowser2 = true } = {}) {
3044
3628
  const deviceId = await (this.facts.devices ?? new DeviceStore()).deviceId();
3045
3629
  const verifier = randomBytes(48).toString("base64url");
3046
3630
  const challenge = createHash3("sha256").update(verifier).digest("base64url");
@@ -3051,7 +3635,7 @@ var AuthClient = class {
3051
3635
  ...clientFact(this.facts.clientVersion) ? { client_version: clientFact(this.facts.clientVersion) } : {},
3052
3636
  ...clientFact(process.platform) ? { os: process.platform } : {}
3053
3637
  };
3054
- const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, redeem, progress) : await this.browserLogin(challenge, verifier, redeem, progress).catch((error) => {
3638
+ const redeemed = device ?? prefersDeviceLogin(this.env) ? await this.deviceLogin(challenge, verifier, redeem, progress) : await this.browserLogin(challenge, verifier, redeem, progress, openBrowser2).catch((error) => {
3055
3639
  if (!(error instanceof BrowserUnavailableError))
3056
3640
  throw error;
3057
3641
  progress({ status: "failed", step: "browser", reason: error.message });
@@ -3087,7 +3671,7 @@ var AuthClient = class {
3087
3671
  throw new Error("The saved login could not be read back.");
3088
3672
  return login;
3089
3673
  }
3090
- async browserLogin(challenge, verifier, redeem, progress) {
3674
+ async browserLogin(challenge, verifier, redeem, progress, openBrowser2) {
3091
3675
  const state = randomBytes(24).toString("base64url");
3092
3676
  const code = await this.listenForCode(state, (redirectUri) => {
3093
3677
  const authorization = new URL("/cli/login", this.loginOrigin);
@@ -3101,7 +3685,7 @@ var AuthClient = class {
3101
3685
  [cliSessionContract.grantParameter]: cliSessionContract.grant
3102
3686
  }).toString();
3103
3687
  return authorization.toString();
3104
- }, progress);
3688
+ }, progress, openBrowser2);
3105
3689
  progress({ status: "started", step: "key-redemption" });
3106
3690
  const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
3107
3691
  method: "POST",
@@ -3179,7 +3763,7 @@ var AuthClient = class {
3179
3763
  }
3180
3764
  }
3181
3765
  }
3182
- listenForCode(expectedState, authorizationUrl, progress) {
3766
+ listenForCode(expectedState, authorizationUrl, progress, openBrowser2) {
3183
3767
  return new Promise((resolve, reject) => {
3184
3768
  let settled = false;
3185
3769
  const sockets = /* @__PURE__ */ new Set();
@@ -3254,6 +3838,8 @@ var AuthClient = class {
3254
3838
  progress({ status: "completed", step: "callback-server" });
3255
3839
  const url = authorizationUrl(`http://${LOOPBACK_HOST}:${port}/callback`);
3256
3840
  progress({ status: "authorization-url", step: "browser", url });
3841
+ if (!openBrowser2)
3842
+ return;
3257
3843
  progress({ status: "started", step: "browser" });
3258
3844
  void Promise.resolve().then(() => this.browserOpener(url)).then(() => startAuthorization(), (error) => {
3259
3845
  if (settled || authorizationStarted)
@@ -3299,6 +3885,150 @@ async function openBrowser(url) {
3299
3885
  }
3300
3886
  }
3301
3887
 
3888
+ // ../../packages/tonbo-cli-core/dist/src/capture-filter.js
3889
+ import { randomUUID as randomUUID3 } from "node:crypto";
3890
+ import { existsSync } from "node:fs";
3891
+ import { mkdir as mkdir3, open as open3, readFile as readFile3, rename as rename3, rm as rm3 } from "node:fs/promises";
3892
+ import os5 from "node:os";
3893
+ import path4 from "node:path";
3894
+ var KEPT_WINDOWS = 200;
3895
+ function yieldStateDirectory(env = process.env, home = os5.homedir()) {
3896
+ return path4.join(env.XDG_STATE_HOME || path4.join(home, ".local", "state"), "tonbo-yield");
3897
+ }
3898
+ function emptyFilter() {
3899
+ return { version: 1, pause: null, past: [], excluded: [] };
3900
+ }
3901
+ function activePause(file, now) {
3902
+ const pause = file.pause;
3903
+ return pause && (pause.until === null || now < pause.until) ? pause : null;
3904
+ }
3905
+ function pauseForLogout(file, now, person) {
3906
+ const current = activePause(file, now);
3907
+ if (current?.logout)
3908
+ return file;
3909
+ const after = current ? { after: current.until } : {};
3910
+ if (current)
3911
+ current.until = now;
3912
+ settle(file, now);
3913
+ file.pause = { since: now, until: null, logout: { ...after, person } };
3914
+ return file;
3915
+ }
3916
+ function endLogoutPause(file, now, person) {
3917
+ const current = activePause(file, now);
3918
+ if (!current?.logout)
3919
+ return file;
3920
+ const { after, person: before } = current.logout;
3921
+ if (person === null || before == null || before !== person)
3922
+ current.since = 0;
3923
+ current.until = now;
3924
+ settle(file, now);
3925
+ if (after === null || after !== void 0 && after > now)
3926
+ file.pause = { since: now, until: after };
3927
+ return file;
3928
+ }
3929
+ function settle(file, now) {
3930
+ if (file.pause && !activePause(file, now)) {
3931
+ file.past = [...file.past, file.pause].slice(-KEPT_WINDOWS);
3932
+ file.pause = null;
3933
+ }
3934
+ }
3935
+ async function durableWrite(filename, body) {
3936
+ const parent = path4.dirname(filename);
3937
+ const created = await mkdir3(parent, { recursive: true, mode: 448 });
3938
+ if (created) {
3939
+ const state = await open3(path4.dirname(parent), "r");
3940
+ try {
3941
+ await state.sync();
3942
+ } finally {
3943
+ await state.close();
3944
+ }
3945
+ }
3946
+ const temporary = path4.join(parent, `.${randomUUID3()}.tmp`);
3947
+ const file = await open3(temporary, "wx", 384);
3948
+ try {
3949
+ await file.writeFile(JSON.stringify(body));
3950
+ await file.sync();
3951
+ } finally {
3952
+ await file.close();
3953
+ }
3954
+ try {
3955
+ await rename3(temporary, filename);
3956
+ const dir = await open3(parent, "r");
3957
+ try {
3958
+ await dir.sync();
3959
+ } finally {
3960
+ await dir.close();
3961
+ }
3962
+ } catch (error) {
3963
+ await rm3(temporary, { force: true });
3964
+ throw error;
3965
+ }
3966
+ }
3967
+ var CaptureFilterStore = class {
3968
+ filename;
3969
+ constructor(filename = path4.join(yieldStateDirectory(), "capture-filter.json")) {
3970
+ this.filename = filename;
3971
+ }
3972
+ /** A missing file filters nothing; an unreadable one throws, and the
3973
+ * daemon then captures nothing (fail closed). */
3974
+ async load() {
3975
+ let body;
3976
+ try {
3977
+ body = await readFile3(this.filename, "utf8");
3978
+ } catch (error) {
3979
+ if (error.code === "ENOENT")
3980
+ return emptyFilter();
3981
+ throw error;
3982
+ }
3983
+ const parsed = JSON.parse(body);
3984
+ const window = (value) => !!value && Number.isFinite(value.since) && (value.until === null || Number.isFinite(value.until));
3985
+ if (parsed?.version !== 1 || !(parsed.pause === null || window(parsed.pause)) || !Array.isArray(parsed.past) || !parsed.past.every(window) || !Array.isArray(parsed.excluded) || !parsed.excluded.every((folder) => typeof folder === "string" && path4.isAbsolute(folder)))
3986
+ throw new Error(`${this.filename} is not a Yield capture filter file.`);
3987
+ return parsed;
3988
+ }
3989
+ async save(file) {
3990
+ await durableWrite(this.filename, file);
3991
+ }
3992
+ async update(change) {
3993
+ const file = await this.load();
3994
+ change(file);
3995
+ await this.save(file);
3996
+ return file;
3997
+ }
3998
+ };
3999
+ var CaptureNotStoppedError = class extends Error {
4000
+ };
4001
+ var reason = (error) => error instanceof Error ? error.message : String(error);
4002
+ async function stopCaptureForLogout(person, store = new CaptureFilterStore(), now = Date.now()) {
4003
+ if (!existsSync(path4.dirname(store.filename)))
4004
+ return null;
4005
+ let file;
4006
+ try {
4007
+ file = await store.load();
4008
+ } catch (error) {
4009
+ return `Yield's capture stays stopped on this computer: ${reason(error)}`;
4010
+ }
4011
+ pauseForLogout(file, now, person);
4012
+ try {
4013
+ await store.save(file);
4014
+ } catch (error) {
4015
+ throw new CaptureNotStoppedError(`Could not stop Yield's capture on this computer (${reason(error)}), so nothing was logged out. Run the logout again where it can write ${store.filename}, outside an agent's sandbox.`, { cause: error });
4016
+ }
4017
+ return null;
4018
+ }
4019
+ async function resumeCaptureAfterLogin(person, store = new CaptureFilterStore(), now = Date.now()) {
4020
+ try {
4021
+ const file = await store.load();
4022
+ if (!activePause(file, now)?.logout)
4023
+ return null;
4024
+ endLogoutPause(file, now, await person().catch(() => null));
4025
+ await store.save(file);
4026
+ return null;
4027
+ } catch (error) {
4028
+ return `Yield's capture stays stopped on this computer: ${reason(error)}`;
4029
+ }
4030
+ }
4031
+
3302
4032
  // ../../packages/tonbo-cli-core/dist/src/cell.js
3303
4033
  function cellOrigin(region, configured = process.env.TONBO_CELL_API_ORIGIN) {
3304
4034
  if (!/^[a-z0-9-]+$/.test(region))
@@ -3316,7 +4046,7 @@ var CellApi = class {
3316
4046
  }
3317
4047
  url(operationId, region, request) {
3318
4048
  const operation = managementOperations[operationId];
3319
- const path4 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
4049
+ const path5 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
3320
4050
  const value = request.params[name];
3321
4051
  if (value === void 0)
3322
4052
  throw new Error(`${operationId} needs the path parameter ${name}.`);
@@ -3327,7 +4057,7 @@ var CellApi = class {
3327
4057
  if (value !== void 0)
3328
4058
  search.set(name, value);
3329
4059
  const query = search.toString();
3330
- return `${cellOrigin(region)}${managementApiBasePath}${path4}${query ? `?${query}` : ""}`;
4060
+ return `${cellOrigin(region)}${managementApiBasePath}${path5}${query ? `?${query}` : ""}`;
3331
4061
  }
3332
4062
  async init(operationId, key, request) {
3333
4063
  const headers = {
@@ -3405,14 +4135,22 @@ function when(timestamp, absent = "never") {
3405
4135
  }
3406
4136
 
3407
4137
  // ../../packages/tonbo-cli-core/dist/src/login.js
4138
+ function captureAfterLogin(deps) {
4139
+ return resumeCaptureAfterLogin(async () => {
4140
+ const file = await deps.credentials.load();
4141
+ return file ? personalLogin(file)?.account_id ?? null : null;
4142
+ }, deps.captureFilter);
4143
+ }
3408
4144
  async function loginCommand(deps, options = {}) {
3409
4145
  try {
3410
4146
  const login = await deps.auth.login((event) => reportLoginProgress(deps.progress, event), options.account, { current: options.current, device: options.device });
4147
+ const stopped = await captureAfterLogin(deps);
3411
4148
  const file = await deps.credentials.load();
3412
4149
  const names = file ? accountLogins(file).map((entry) => entry.kind === "user" ? `${entry.name ?? "you"} (personal)` : entry.name ?? entry.account_id) : [];
3413
4150
  deps.output([
3414
4151
  `Logged in. This computer can act in every account you belong to, with your role in each${names.length ? `: ${names.join(", ")}` : ""}.`,
3415
- `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.` : ""}`
4152
+ `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.` : ""}`,
4153
+ ...stopped ? [stopped] : []
3416
4154
  ].join("\n"));
3417
4155
  return login;
3418
4156
  } catch (error) {
@@ -3484,11 +4222,13 @@ async function logoutCommand(deps) {
3484
4222
  failures.push(`${what}: ${failure.message}`);
3485
4223
  };
3486
4224
  let session = null;
4225
+ let unusable = false;
3487
4226
  try {
3488
4227
  session = await deps.credentials.session({ anyDevice: true });
3489
4228
  } catch (error) {
3490
4229
  if (!(error instanceof UnusableLoginError))
3491
4230
  throw error;
4231
+ unusable = true;
3492
4232
  }
3493
4233
  let legacy = null;
3494
4234
  try {
@@ -3496,6 +4236,13 @@ async function logoutCommand(deps) {
3496
4236
  } catch (error) {
3497
4237
  if (!(error instanceof UnusableLoginError))
3498
4238
  throw error;
4239
+ unusable = true;
4240
+ }
4241
+ if (session || legacy || unusable) {
4242
+ const person = session?.stored.personal_account_id ?? (legacy && personalLogin(legacy)?.account_id) ?? null;
4243
+ const stopped = await stopCaptureForLogout(person, deps.captureFilter);
4244
+ if (stopped)
4245
+ deps.output(stopped);
3499
4246
  }
3500
4247
  if (session)
3501
4248
  await session.logout().catch((error) => failed("the CLI login", error));
@@ -3604,7 +4351,7 @@ var TerminalProgress = class {
3604
4351
  };
3605
4352
 
3606
4353
  // src/commands.ts
3607
- import { randomUUID as randomUUID3 } from "node:crypto";
4354
+ import { randomUUID as randomUUID5 } from "node:crypto";
3608
4355
 
3609
4356
  // src/agent-name.ts
3610
4357
  var AGENT_NAME_RULE = agentNameContract.$defs.name;
@@ -3619,6 +4366,209 @@ function validateAgentName(name) {
3619
4366
  }
3620
4367
  }
3621
4368
 
4369
+ // src/budget.ts
4370
+ import { randomUUID as randomUUID4 } from "node:crypto";
4371
+ var BUDGETS_NOT_AVAILABLE = "Budgets are not available yet.";
4372
+ function parseDollars(value) {
4373
+ const match = /^\$?([0-9]+)(?:\.([0-9]{1,2}))?$/.exec(value.trim());
4374
+ if (!match) throw new Error(`${value} is not an amount in dollars, such as $50 or 12.50.`);
4375
+ return Number(match[1]) * 1e6 + Number((match[2] ?? "").padEnd(2, "0")) * 1e4;
4376
+ }
4377
+ var isAmount = (value) => /^\$?[0-9]+(?:\.[0-9]{1,2})?$/.test(value.trim());
4378
+ function unavailable(error) {
4379
+ if (error instanceof HttpError && error.status === 404 && error.code !== "budget_not_found")
4380
+ return new HttpError(BUDGETS_NOT_AVAILABLE, 404, error.body, error.requestId);
4381
+ return error;
4382
+ }
4383
+ async function budgets(deps, key) {
4384
+ return deps.api.listBudgets(key).catch((error) => {
4385
+ throw unavailable(error);
4386
+ });
4387
+ }
4388
+ async function resolveBudget(deps, key, budget) {
4389
+ const all = await budgets(deps, key);
4390
+ const found = all.find((entry) => entry.id === budget || entry.name === budget);
4391
+ if (!found) throw new Error(`No budget of this account is ${budget}. See \`tonbo budget list\`.`);
4392
+ return found;
4393
+ }
4394
+ function usedText(budget) {
4395
+ if (budget.limit_micros === void 0) return "\u2014";
4396
+ const used = (budget.settled_micros ?? 0) + (budget.committed_micros ?? 0);
4397
+ return `${usd(used)} of ${usd(budget.limit_micros)}`;
4398
+ }
4399
+ function budgetFields(budget) {
4400
+ const rows = [
4401
+ ["ID", budget.id],
4402
+ ["Name", budget.name ?? "\u2014"],
4403
+ ["Scope", budget.scope],
4404
+ ["Stage", budget.stage]
4405
+ ];
4406
+ if (budget.limit_micros !== void 0) {
4407
+ rows.push(["Limit", usd(budget.limit_micros)]);
4408
+ rows.push(["Charged", usd(budget.settled_micros ?? 0)]);
4409
+ rows.push(["Committed", usd(budget.committed_micros ?? 0)]);
4410
+ }
4411
+ if (budget.principals?.length) rows.push(["Covers", budget.principals.join(", ")]);
4412
+ if (budget.write_pace)
4413
+ rows.push([
4414
+ "Writes paced",
4415
+ `${budget.write_pace.units_per_second}/s (floor ${budget.write_pace.floor_units_per_second}/s)`
4416
+ ]);
4417
+ rows.push(["Resets", budget.resets_at]);
4418
+ rows.push(["Raise at", budget.raise_url]);
4419
+ return fields(rows);
4420
+ }
4421
+ async function budgetListCommand(deps, options) {
4422
+ const all = await budgets(deps, await deps.auth.apiKey());
4423
+ print(
4424
+ deps,
4425
+ options.json,
4426
+ all,
4427
+ all.length ? table(
4428
+ ["ID", "NAME", "SCOPE", "STAGE", "USED", "RESETS"],
4429
+ all.map((budget) => [
4430
+ budget.id,
4431
+ budget.name ?? "\u2014",
4432
+ budget.scope,
4433
+ budget.stage,
4434
+ usedText(budget),
4435
+ budget.resets_at
4436
+ ])
4437
+ ) : "No budgets. An owner or admin sets the account budget in the console; `tonbo budget add` sets one for you or an Agent you created."
4438
+ );
4439
+ }
4440
+ async function budgetShowCommand(deps, budget, options) {
4441
+ const found = await resolveBudget(deps, await deps.auth.apiKey(), budget);
4442
+ print(deps, options.json, found, budgetFields(found));
4443
+ }
4444
+ async function sendToConsole(deps, url, why) {
4445
+ deps.notice(`${why} An owner or admin makes it in the console: ${url}`);
4446
+ if (deps.interactive() && deps.openUrl) await deps.openUrl(url).catch(() => void 0);
4447
+ }
4448
+ async function consoleOr(deps, error) {
4449
+ if (error instanceof HttpError && error.code === "budget_raise_in_console") {
4450
+ const body = error.body;
4451
+ if (typeof body?.raise_url === "string") {
4452
+ await sendToConsole(deps, body.raise_url, "Only the console makes this change.");
4453
+ throw new HttpError(error.message, error.status, error.body, error.requestId);
4454
+ }
4455
+ }
4456
+ throw unavailable(error);
4457
+ }
4458
+ async function budgetAddCommand(deps, name, options) {
4459
+ const key = await deps.auth.apiKey();
4460
+ const limit = parseDollars(options.limit);
4461
+ const principals = options.member?.length ? await Promise.all(options.member.map((member) => resolveMemberSubject(deps, key, member))) : [(await deps.api.getAccount(key)).member.subject];
4462
+ const created = await deps.api.createBudget(key, { scope: "principals", name, limit_micros: limit, principals }, randomUUID4()).catch((error) => consoleOr(deps, error));
4463
+ print(
4464
+ deps,
4465
+ options.json,
4466
+ created,
4467
+ `Created budget ${created.name ?? name}: ${usd(limit)} a month, covering ${principals.join(", ")}.`
4468
+ );
4469
+ }
4470
+ async function budgetSetCommand(deps, budget, options) {
4471
+ if (options.limit === void 0 && !options.add?.length)
4472
+ throw new Error("Nothing to change: pass --limit, --add or both.");
4473
+ const key = await deps.auth.apiKey();
4474
+ const found = await resolveBudget(deps, key, budget);
4475
+ const limit = options.limit === void 0 ? void 0 : parseDollars(options.limit);
4476
+ if (limit !== void 0 && (found.limit_micros === void 0 || limit >= found.limit_micros)) {
4477
+ await sendToConsole(
4478
+ deps,
4479
+ found.raise_url,
4480
+ found.limit_micros === void 0 ? "This budget is not yours to change here." : `${usd(limit)} is not below the budget's ${usd(found.limit_micros)}: the CLI only lowers a budget.`
4481
+ );
4482
+ throw new Error("The budget was not changed.");
4483
+ }
4484
+ const add = options.add?.length ? await Promise.all(options.add.map((member) => resolveMemberSubject(deps, key, member))) : void 0;
4485
+ const changed = await deps.api.changeBudget(key, found.id, { limit_micros: limit, add_principals: add }).catch((error) => consoleOr(deps, error));
4486
+ print(deps, options.json, changed, budgetFields(changed));
4487
+ }
4488
+ async function attachBudget(deps, key, subject, value, name, created) {
4489
+ try {
4490
+ if (isAmount(value)) {
4491
+ const limit = parseDollars(value);
4492
+ const budget = await deps.api.createBudget(
4493
+ key,
4494
+ { scope: "principals", name, limit_micros: limit, principals: [subject] },
4495
+ randomUUID4()
4496
+ ).catch((error) => consoleOr(deps, error));
4497
+ deps.notice(`Budget ${budget.name ?? name}: ${usd(limit)} a month for ${subject}.`);
4498
+ return;
4499
+ }
4500
+ const found = await resolveBudget(deps, key, value);
4501
+ await deps.api.changeBudget(key, found.id, { add_principals: [subject] }).catch((error) => consoleOr(deps, error));
4502
+ deps.notice(`Budget ${found.name ?? found.id} now covers ${subject}.`);
4503
+ } catch (error) {
4504
+ const reason2 = error instanceof Error ? error.message : String(error);
4505
+ throw new Error(`${created} was created, but its budget was not set: ${reason2}`);
4506
+ }
4507
+ }
4508
+ function accountBudgetFields(account) {
4509
+ const budgets2 = account.budgets;
4510
+ if (!budgets2) return [];
4511
+ const line = (budget) => budget ? `${budget.name ?? budget.id} \xB7 ${budget.stage} \xB7 ${usedText(budget)} \xB7 resets ${budget.resets_at}` : "none";
4512
+ const rows = [
4513
+ ["Account budget", line(budgets2.account)],
4514
+ ["Your budget", line(budgets2.caller)]
4515
+ ];
4516
+ const pace = budgets2.account?.write_pace;
4517
+ if (pace)
4518
+ rows.push([
4519
+ "Writes paced",
4520
+ `${pace.units_per_second}/s across regions (floor ${pace.floor_units_per_second}/s)`
4521
+ ]);
4522
+ return rows;
4523
+ }
4524
+ function refusalBudget(body) {
4525
+ const budget = body?.budget;
4526
+ if (!budget || typeof budget !== "object") return null;
4527
+ const record = budget;
4528
+ return typeof record.id === "string" && typeof record.raise_url === "string" ? record : null;
4529
+ }
4530
+ function budgetHint(error) {
4531
+ const budget = refusalBudget(error.body);
4532
+ const name = budget ? budget.name ?? budget.id : "a budget";
4533
+ switch (error.code) {
4534
+ case "budget_exhausted":
4535
+ return `The budget ${name} is used up${budget?.retry_at ? ` until ${budget.retry_at}` : ""}: stop, and try again only after then. A person can raise it in the console${budget ? ` at ${budget.raise_url}` : ""}.`;
4536
+ case "budget_slowdown":
4537
+ return `The budget ${name} is close to its limit, so only a few of its Machines may be awake at once: sleep another of its Machines${budget?.largest_shape_that_fits ? `, or set this one's shape to ${budget.largest_shape_that_fits}` : ""}, then wake it again. A person can raise it${budget ? ` at ${budget.raise_url}` : " in the console"}.`;
4538
+ case "budget_scope":
4539
+ return "Your budget confines you to the Machines you created: work on one of those, or ask a person to.";
4540
+ case "budget_raise_in_console": {
4541
+ const url = error.body?.raise_url;
4542
+ return `Only the console makes this change${typeof url === "string" ? `: ${url}` : "."}`;
4543
+ }
4544
+ default:
4545
+ return null;
4546
+ }
4547
+ }
4548
+ function budgetHeaderLine(header) {
4549
+ const values = /* @__PURE__ */ new Map();
4550
+ for (const member of header.split(",")) {
4551
+ const [name, ...rest] = member.trim().split("=");
4552
+ if (!name) continue;
4553
+ values.set(name.trim(), rest.join("=").trim().replace(/^"|"$/g, ""));
4554
+ }
4555
+ const used = values.get("used");
4556
+ const percent = used === void 0 ? "" : `, ${Math.round(Number(used) * 100)}% used`;
4557
+ return `Budget: ${values.get("scope") === "account" ? "the account budget" : "your budget"} is at ${values.get("stage") ?? "notice"}${percent}; it resets ${values.get("resets") ?? "on the 1st"}.`;
4558
+ }
4559
+ function noticingBudget(fetcher, notice) {
4560
+ let said = false;
4561
+ return async (input, init) => {
4562
+ const response = await fetcher(input, init);
4563
+ const header = response.headers.get("tonbo-budget");
4564
+ if (header && !said) {
4565
+ said = true;
4566
+ notice(budgetHeaderLine(header));
4567
+ }
4568
+ return response;
4569
+ };
4570
+ }
4571
+
3622
4572
  // src/commands.ts
3623
4573
  function print(deps, json, data, human) {
3624
4574
  deps.output(json ? JSON.stringify(data) : human);
@@ -3661,7 +4611,7 @@ async function agentCreateCommand(deps, name, options = {}) {
3661
4611
  const agent = await deps.api.createAgent(
3662
4612
  key,
3663
4613
  { name, region, ...options.role === void 0 ? {} : { role: options.role } },
3664
- options.idempotencyKey ?? randomUUID3()
4614
+ options.idempotencyKey ?? randomUUID5()
3665
4615
  );
3666
4616
  print(
3667
4617
  deps,
@@ -3674,6 +4624,15 @@ async function agentCreateCommand(deps, name, options = {}) {
3674
4624
  `Region: ${agent.region}`
3675
4625
  ].join("\n")
3676
4626
  );
4627
+ if (options.budget !== void 0)
4628
+ await attachBudget(
4629
+ deps,
4630
+ key,
4631
+ `agent.${agent.id}`,
4632
+ options.budget,
4633
+ agent.name,
4634
+ `The Agent ${agent.name}`
4635
+ );
3677
4636
  }
3678
4637
  async function accountShowCommand(deps, options = {}) {
3679
4638
  const account = await deps.api.getAccount(await deps.auth.apiKey());
@@ -3687,7 +4646,8 @@ async function accountShowCommand(deps, options = {}) {
3687
4646
  ["Kind", account.kind],
3688
4647
  ["Your role", account.member.role],
3689
4648
  ["Your subject", account.member.subject],
3690
- ["Bucket limit", String(account.limits.buckets)]
4649
+ ["Bucket limit", String(account.limits.buckets)],
4650
+ ...accountBudgetFields(account)
3691
4651
  ])
3692
4652
  );
3693
4653
  }
@@ -3892,6 +4852,15 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
3892
4852
  deps.notice(
3893
4853
  "Do not set TONBO_API_KEY in your own shell: it overrides your `tonbo login`, for this CLI too."
3894
4854
  );
4855
+ if (options.budget !== void 0)
4856
+ await attachBudget(
4857
+ deps,
4858
+ key,
4859
+ created.member,
4860
+ options.budget,
4861
+ created.name,
4862
+ `The API key ${created.name}`
4863
+ );
3895
4864
  }
3896
4865
  async function apiKeyShowCommand(deps, keyId, options = {}) {
3897
4866
  const apiKey = await deps.api.getApiKey(await deps.auth.apiKey(), keyId);
@@ -3950,7 +4919,7 @@ async function bucketCreateCommand(deps, name, options = {}) {
3950
4919
  const bucket = await deps.api.createBucket(
3951
4920
  await deps.auth.apiKey(),
3952
4921
  { name, region: options.region },
3953
- options.idempotencyKey ?? randomUUID3()
4922
+ options.idempotencyKey ?? randomUUID5()
3954
4923
  );
3955
4924
  print(
3956
4925
  deps,
@@ -4081,7 +5050,7 @@ async function databaseCreateCommand(deps, name, options = {}) {
4081
5050
  const database = await deps.api.createDatabase(
4082
5051
  await deps.auth.apiKey(),
4083
5052
  { name, region: options.region, expires_at: expiresAt },
4084
- options.idempotencyKey ?? randomUUID3()
5053
+ options.idempotencyKey ?? randomUUID5()
4085
5054
  );
4086
5055
  print(
4087
5056
  deps,
@@ -4269,7 +5238,7 @@ async function streamCreateCommand(deps, name, options = {}) {
4269
5238
  content_type: options.contentType,
4270
5239
  expires_at: expiresAt
4271
5240
  },
4272
- options.idempotencyKey ?? randomUUID3()
5241
+ options.idempotencyKey ?? randomUUID5()
4273
5242
  );
4274
5243
  print(
4275
5244
  deps,
@@ -4493,11 +5462,11 @@ async function accountUseCommand(deps, account, options = {}) {
4493
5462
  import { Help } from "commander";
4494
5463
 
4495
5464
  // src/machine.ts
4496
- import { randomUUID as randomUUID5 } from "node:crypto";
5465
+ import { randomUUID as randomUUID7 } from "node:crypto";
4497
5466
  import { readFileSync } from "node:fs";
4498
5467
 
4499
5468
  // src/volume.ts
4500
- import { randomUUID as randomUUID4 } from "node:crypto";
5469
+ import { randomUUID as randomUUID6 } from "node:crypto";
4501
5470
  var VOLUME_DURABILITY_LINK = "https://tonbo.dev/docs/machines/guarantees/data#volume";
4502
5471
  var VOLUME_ERROR_HINTS = {
4503
5472
  payment_required: "Add credit at <billing>, then create the Volume again.",
@@ -4595,7 +5564,7 @@ async function volumeCreateCommand(deps, name, options = {}) {
4595
5564
  const volume = await deps.api.createVolume(
4596
5565
  key,
4597
5566
  { name, region, size_gib: sizeGib },
4598
- options.idempotencyKey ?? randomUUID4()
5567
+ options.idempotencyKey ?? randomUUID6()
4599
5568
  );
4600
5569
  print(
4601
5570
  deps,
@@ -4662,7 +5631,8 @@ var MACHINE_ERROR_HINTS = {
4662
5631
  machine_kind_mismatch: "That Machine is one of your computers: `tonbo machine show` and `tonbo machine delete` serve it, and nothing else does.",
4663
5632
  volumes_not_offered: "Volumes are not offered to this account in the Machine's region yet; nothing was created or changed.",
4664
5633
  secret_env_conflict: "Use another secret key or remove the non-secret env variable with that name.",
4665
- secret_limit: "Reduce the number or byte size of this Machine's secrets."
5634
+ secret_limit: "Reduce the number or byte size of this Machine's secrets.",
5635
+ wake_unavailable: "The wake was refused for now; nothing woke. Wake it again after a while; `tonbo machine show` says why it is blocked."
4666
5636
  };
4667
5637
  var MACHINE_IMAGE_FAILURE_HINTS = {
4668
5638
  image_unavailable: "The registry no longer serves that image publicly, or served other bytes for it: publish it again, or use another image",
@@ -4694,7 +5664,7 @@ function ifMatchHeader(value) {
4694
5664
  }
4695
5665
  function explainMachineError(error) {
4696
5666
  if (!(error instanceof HttpError) || error.code === null) return error;
4697
- const hint = MACHINE_ERROR_HINTS[error.code];
5667
+ const hint = budgetHint(error) ?? MACHINE_ERROR_HINTS[error.code];
4698
5668
  if (!hint) return error;
4699
5669
  return new HttpError(`${error.message}
4700
5670
  ${hint}`, error.status, error.body, error.requestId);
@@ -4724,7 +5694,13 @@ function portText(port) {
4724
5694
  function machinePortsText(machine) {
4725
5695
  return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
4726
5696
  }
4727
- var { earlyReclaimSeconds, pollAfterSeconds, sleepPollAfterSeconds, wakeHoldSeconds } = machineTimings;
5697
+ var {
5698
+ earlyReclaimSeconds,
5699
+ pollAfterSeconds,
5700
+ sleepPollAfterSeconds,
5701
+ wakePollAfterSeconds,
5702
+ wakeHoldSeconds
5703
+ } = machineTimings;
4728
5704
  function machineState(machine) {
4729
5705
  if (machine.failure)
4730
5706
  return `${machine.state}: ${machine.failure.code}: ${machine.failure.message}`;
@@ -4744,10 +5720,10 @@ function duration(seconds) {
4744
5720
  return seconds % 60 === 0 && seconds >= 120 ? `${seconds / 60} min` : `${seconds} s`;
4745
5721
  }
4746
5722
  function sleepText(machine) {
4747
- const { action, reason } = machine.status.sleep;
5723
+ const { action, reason: reason2 } = machine.status.sleep;
4748
5724
  const when2 = `after about ${duration(earlyReclaimSeconds)} idle (early) or at most ${machine.sleep_after_seconds} s without a connection`;
4749
5725
  if (action !== "release") return `${when2}; it then ${action}s`;
4750
- return `${when2}; the disk resets at every sleep${reason === "suspend_unavailable" ? " until suspend ships" : ""}`;
5726
+ return `${when2}; the disk resets at every sleep${reason2 === "suspend_unavailable" ? " until suspend ships" : ""}`;
4751
5727
  }
4752
5728
  function activationFields(machine) {
4753
5729
  const { activation } = machine;
@@ -5182,9 +6158,9 @@ async function waitForCreate(deps, key, machine, name, firstWait, timeout, json)
5182
6158
  }
5183
6159
  print(deps, json, settled, machineFields(settled, billing));
5184
6160
  if (settled.state === "failed") {
5185
- const { reason, hint } = creationFailureText(settled, billing);
6161
+ const { reason: reason2, hint } = creationFailureText(settled, billing);
5186
6162
  throw new Error(
5187
- `Machine ${settled.name} failed to be created${reason}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
6163
+ `Machine ${settled.name} failed to be created${reason2}; delete it with \`tonbo machine delete ${settled.name}\` and create it again.${hint}`
5188
6164
  );
5189
6165
  }
5190
6166
  throw new Error(`Machine ${settled.name} is ${settled.state}; it was not created.`);
@@ -5226,7 +6202,7 @@ async function machineCreateCommand(deps, name, command, options = {}) {
5226
6202
  ...database ? { database: database.name } : {},
5227
6203
  ...options.database === false ? { database: null } : {}
5228
6204
  },
5229
- options.idempotencyKey ?? randomUUID5()
6205
+ options.idempotencyKey ?? randomUUID7()
5230
6206
  );
5231
6207
  } catch (error) {
5232
6208
  if (error instanceof HttpError && error.code === "name_taken")
@@ -5238,9 +6214,9 @@ async function machineCreateCommand(deps, name, command, options = {}) {
5238
6214
  if (machine.state === "failed") {
5239
6215
  const billing = await billingUrl(deps);
5240
6216
  print(deps, options.json, machine, machineFields(machine, billing));
5241
- const { reason, hint } = creationFailureText(machine, billing);
6217
+ const { reason: reason2, hint } = creationFailureText(machine, billing);
5242
6218
  throw new Error(
5243
- `Machine ${machine.name} failed to be created${reason}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
6219
+ `Machine ${machine.name} failed to be created${reason2}; delete it with \`tonbo machine delete ${machine.name}\` and create it again.${hint}`
5244
6220
  );
5245
6221
  }
5246
6222
  if (options.wait && machine.state === "creating")
@@ -5266,11 +6242,16 @@ async function machineShowCommand(deps, name, options = {}) {
5266
6242
  const key = await deps.auth.apiKey();
5267
6243
  const { machine, etag } = await deps.api.getMachineWithEtag(key, name);
5268
6244
  const lastEnd = options.json || machine.kind === "byoc" || machine.state !== "active" ? null : await lastActivationEnd(deps, key, machine.name);
6245
+ const processes = options.json || machine.kind === "byoc" || machine.state !== "active" || !deps.processes ? null : await deps.processes(key, machine.region, machine.name);
6246
+ const processLines = processes ? [
6247
+ ...processes.running.map((line) => `Background process: ${line}`),
6248
+ ...processes.last ? [`Last background process: ${processes.last}`] : []
6249
+ ] : [];
5269
6250
  print(
5270
6251
  deps,
5271
6252
  options.json,
5272
6253
  machine,
5273
- anyMachineText(machine, await billingUrl(deps), etag, lastEnd)
6254
+ [anyMachineText(machine, await billingUrl(deps), etag, lastEnd), ...processLines].join("\n")
5274
6255
  );
5275
6256
  });
5276
6257
  }
@@ -5279,9 +6260,9 @@ async function lastActivationEnd(deps, key, name) {
5279
6260
  const page = await deps.api.listMachineActivations(key, name, { limit: 2 });
5280
6261
  return page.data.find((entry) => entry.end !== null)?.end ?? null;
5281
6262
  } catch (error) {
5282
- if (!(error instanceof HttpError)) throw error;
6263
+ if (!(error instanceof HttpError || error instanceof AccessTokenError)) throw error;
5283
6264
  deps.notice(
5284
- `Could not read how its last activation ended (${error.status}); \`tonbo machine activations ${name}\` lists them.`
6265
+ `Could not read how its last activation ended (${error.status ?? "no answer"}); \`tonbo machine activations ${name}\` lists them.`
5285
6266
  );
5286
6267
  return null;
5287
6268
  }
@@ -5318,9 +6299,14 @@ function appliesText(name, change) {
5318
6299
  lines.push(
5319
6300
  change.delete_after_seconds === null ? "The Machine is no longer deleted for being asleep." : "Delete-after applies now; `tonbo machine show` says when."
5320
6301
  );
5321
- const later = [change.image, change.command, change.shape, change.env, change.volumes].some(
5322
- (field) => field !== void 0
5323
- );
6302
+ const later = [
6303
+ change.image,
6304
+ change.command,
6305
+ change.shape,
6306
+ change.env,
6307
+ change.volumes,
6308
+ change.database
6309
+ ].some((field) => field !== void 0);
5324
6310
  if (later) {
5325
6311
  lines.push(
5326
6312
  `The change applies at the next activation. To apply it now, run \`tonbo machine sleep ${name}\`; the next request boots the new spec.`
@@ -5370,6 +6356,8 @@ async function machineUpdateCommand(deps, name, command, options = {}) {
5370
6356
  if (options.volume === false) change.volumes = [];
5371
6357
  else if (typeof options.volume === "string")
5372
6358
  change.volumes = [(await resolveVolumeBinding(deps, key, options.volume)).binding];
6359
+ if (options.database === false) change.database = null;
6360
+ else if (typeof options.database === "string") change.database = options.database;
5373
6361
  if (options.discardSnapshot) change.discard_snapshot = true;
5374
6362
  if (Object.values(change).every((value) => value === void 0))
5375
6363
  throw new Error("Nothing to change: pass at least one option or a command.");
@@ -5431,6 +6419,42 @@ async function machineSleepCommand(deps, name, options = {}) {
5431
6419
  );
5432
6420
  });
5433
6421
  }
6422
+ function wokenText(machine, waking) {
6423
+ if (!waking) return `Machine ${machine.name} is awake.`;
6424
+ return `Machine ${machine.name} is waking; \`tonbo machine show ${machine.name}\` says when it is awake.`;
6425
+ }
6426
+ async function machineWakeCommand(deps, name, options = {}) {
6427
+ await machineCommand(async () => {
6428
+ const timeout = waitTimeout(options);
6429
+ const key = await deps.auth.apiKey();
6430
+ const { machine, retryAfterSeconds: retryAfterSeconds2 } = await deps.api.wakeMachine(
6431
+ key,
6432
+ name,
6433
+ ifMatchHeader(options.ifMatch)
6434
+ );
6435
+ const waking = retryAfterSeconds2 !== null;
6436
+ if (!options.wait || !waking) {
6437
+ print(deps, options.json, machine, wokenText(machine, waking));
6438
+ return;
6439
+ }
6440
+ const { machine: awake, waited } = await waitForMachine(
6441
+ deps,
6442
+ key,
6443
+ name,
6444
+ retryAfterSeconds2 ?? wakePollAfterSeconds,
6445
+ wakePollAfterSeconds,
6446
+ timeout,
6447
+ (read) => read.status.power === "awake" || read.state !== "active"
6448
+ );
6449
+ if (!awake)
6450
+ throw new Error(
6451
+ `Machine ${name} is not awake after ${waited} s; \`tonbo machine show ${name}\` says why.`
6452
+ );
6453
+ if (awake.status.power !== "awake")
6454
+ throw new Error(`Machine ${name} is ${awake.state}; it did not wake.`);
6455
+ print(deps, options.json, awake, `Machine ${awake.name} is awake (after ${waited} s).`);
6456
+ });
6457
+ }
5434
6458
  async function machineDeleteCommand(deps, name, options = {}) {
5435
6459
  await machineCommand(async () => {
5436
6460
  const key = await deps.auth.apiKey();
@@ -5567,7 +6591,7 @@ var TOPICS = [
5567
6591
  title: "The login, accounts and keys",
5568
6592
  text: [
5569
6593
  "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.",
5570
- "`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.",
6594
+ "`TONBO_API_KEY`, when it is set, is used instead: an API key reaches only its own account, as narrowed by the key. A personal key is sent as the Bearer credential. An Agent key, which the API never accepts directly, is exchanged at `POST https://tonbo.dev/api/iam/token` for a token of at most 10 minutes, kept in memory and exchanged again shortly before it expires; neither the key nor the token is ever printed. 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.",
5571
6595
  "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."
5572
6596
  ]
5573
6597
  },
@@ -5613,7 +6637,7 @@ var COMMAND_HELP = {
5613
6637
  },
5614
6638
  "tonbo logout": {
5615
6639
  about: [
5616
- "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."
6640
+ "Ends this computer's login for every account and removes the file, which signs `yield` out too. Before anything is revoked, Yield's capture on this computer stops until the next login of either CLI, and nothing written meanwhile is ever recorded; when it cannot write Yield's folder to stop it, nothing is logged out. 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."
5617
6641
  ],
5618
6642
  examples: ["tonbo logout"]
5619
6643
  },
@@ -5676,6 +6700,34 @@ var COMMAND_HELP = {
5676
6700
  "--idempotency-key": "a new UUID"
5677
6701
  }
5678
6702
  },
6703
+ "tonbo budget list": {
6704
+ about: [
6705
+ "Lists the account's monthly spend budgets, the account budget first, with each one's stage and what it has used of its limit this UTC month. You see a budget's figures with `usage.read`, or when it covers you; of any other budget only its stage, reset and console link."
6706
+ ],
6707
+ examples: ["tonbo budget list"]
6708
+ },
6709
+ "tonbo budget show": {
6710
+ about: [
6711
+ "Prints one budget: its limit, what was charged and what is committed this month, whom it covers, its stage, when it resets (00:00 UTC on the 1st) and its page in the console."
6712
+ ],
6713
+ examples: ["tonbo budget show pi-agent"],
6714
+ arguments: { budget: "the budget's name or id (`bdg_\u2026`)" }
6715
+ },
6716
+ "tonbo budget add": {
6717
+ about: [
6718
+ "Creates a budget over you, or over Agents you created, which counts the compute and outbound data of the Machines they create. Only the console sets the account budget or covers another member."
6719
+ ],
6720
+ examples: ["tonbo budget add pi-agent --limit 50 --member pi-agent"],
6721
+ arguments: { name: "the budget's name, unique in the account" },
6722
+ defaults: { "--member": "yourself" }
6723
+ },
6724
+ "tonbo budget set": {
6725
+ about: [
6726
+ "Lowers the budget that covers you, or adds Agents you created to it. Raising a limit, renaming, removing and every change to another budget are the console's: the command prints the budget's page and opens it instead."
6727
+ ],
6728
+ examples: ["tonbo budget set pi-agent --limit 25"],
6729
+ arguments: { budget: "the budget's name or id (`bdg_\u2026`)" }
6730
+ },
5679
6731
  "tonbo api-key list": {
5680
6732
  about: [
5681
6733
  "Shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret, whether it is read-only, what it reaches of buckets, databases and streams, and when it expires and was last used."
@@ -5913,7 +6965,7 @@ var COMMAND_HELP = {
5913
6965
  "tonbo machine update": {
5914
6966
  about: [
5915
6967
  "Changes only what its options name. It reads the Machine and sends the ETag it read, or `--if-match`'s, so a change someone made in between is refused with `precondition_failed` rather than overwritten; a Machine whose creation failed is refused with `machine_failed`. `--env` and `--unset-env` change single variables; `--port` replaces every port, and the current default port stays the default while it is still listed.",
5916
- "Ports and `--sleep-after` apply at once. The image, command, shape and env apply at the next boot, and the command says how to apply them now: `tonbo machine sleep`, after which the next request boots the new spec. Naming the same tag again resolves its digest again.",
6968
+ "Ports and `--sleep-after` apply at once. The image, command, shape, env, Volume and database apply at the next boot, and the command says how to apply them now: `tonbo machine sleep`, after which the next request boots the new spec. Naming the same tag again resolves its digest again. `--database <name>` binds another database of the account in the Machine's region, which no other Machine binds, and `--no-database` binds none; the database it leaves is kept. A Machine created with its own database keeps it.",
5917
6969
  "`--delete-after` applies at once and keeps the clock, which counts from the end of the Machine's last activation (or its creation), so a lower value can delete a Machine that has already slept that long; `--no-delete-after` turns it off. `tonbo machine show` prints when it will be deleted."
5918
6970
  ],
5919
6971
  examples: ["tonbo machine update web --env MODE=staging --sleep-after 300"],
@@ -5929,11 +6981,13 @@ var COMMAND_HELP = {
5929
6981
  "tonbo machine exec": {
5930
6982
  about: [
5931
6983
  "Runs one command in the Machine's running activation through its region's cell origin, waking the Machine first when it is asleep, and streams its stdout and stderr here as they come; a call that woke the Machine prints its hourly ceiling on stderr. The command runs without a shell, as the image's User, and lives exactly as long as the call: there is no terminal and no streamed stdin, and `--stdin` sends at most 1 MiB read before the call.",
5932
- "The CLI exits with the command's status: 128 plus the signal's number when a signal ended it, 124 at `--timeout`, 125 when it was refused, killed or passed 64 MiB of output, 126 or 127 when it could not run, and 255 when the connection ended first and the outcome is unknown. `--json` prints the answer's document instead, with the first and last 512 KiB of each stream."
6984
+ "The CLI exits with the command's status: 128 plus the signal's number when a signal ended it, 124 at `--timeout`, 125 when it was refused, killed or passed 64 MiB of output, 126 or 127 when it could not run, and 255 when the connection ended first and the outcome is unknown. `--json` prints the answer's document instead, with the first and last 512 KiB of each stream.",
6985
+ "`--detach` starts the command in the background instead, prints its process id and returns at once: it runs whatever happens to this call, keeps the Machine awake until it ends, and is ended at `--timeout` (default 3600, at most 21600). Read it with `tonbo machine output`, wait for it with `tonbo machine wait`, list them with `tonbo machine ps` and end one with `tonbo machine kill`. A budget or funding stop, or a sleep you ask for, still ends it."
5933
6986
  ],
5934
6987
  examples: [
5935
6988
  "tonbo machine exec web -- ls -la /app",
5936
- "tonbo machine exec web --timeout 60 -- npm test"
6989
+ "tonbo machine exec web --timeout 60 -- npm test",
6990
+ "tonbo machine exec web --detach -- npm run build"
5937
6991
  ],
5938
6992
  arguments: {
5939
6993
  name: "the Machine's name",
@@ -5941,6 +6995,37 @@ var COMMAND_HELP = {
5941
6995
  },
5942
6996
  defaults: { "--timeout": "600", "--cwd": "the image's WorkingDir" }
5943
6997
  },
6998
+ "tonbo machine ps": {
6999
+ about: [
7000
+ "Lists the Machine's background processes, newest first: those running and those that ended in the last 24 hours, with how each ended. It never wakes the Machine."
7001
+ ],
7002
+ examples: ["tonbo machine ps web"],
7003
+ arguments: { name: "the Machine's name" }
7004
+ },
7005
+ "tonbo machine wait": {
7006
+ about: [
7007
+ "Waits until a background process ends, prints how it ended and exits with its status as `tonbo machine exec` would: its exit code, 128 plus a signal's number, 124 at its timeout, 125 when it was killed and 255 when its outcome is unknown. It never wakes the Machine."
7008
+ ],
7009
+ examples: ["tonbo machine wait web 019a1f2e5a6b7c8d9e0f1a2b3c4d5e6f"],
7010
+ arguments: { name: "the Machine's name", process: "the process id `exec --detach` printed" }
7011
+ },
7012
+ "tonbo machine output": {
7013
+ about: [
7014
+ "Prints a background process's stdout and stderr together, as it wrote them, from the last 16 MiB the Machine keeps. `--follow` keeps printing until the process ends. The output is gone once the Machine sleeps. It never wakes the Machine."
7015
+ ],
7016
+ examples: [
7017
+ "tonbo machine output web 019a1f2e5a6b7c8d9e0f1a2b3c4d5e6f",
7018
+ "tonbo machine output web 019a1f2e5a6b7c8d9e0f1a2b3c4d5e6f --follow"
7019
+ ],
7020
+ arguments: { name: "the Machine's name", process: "the process id `exec --detach` printed" }
7021
+ },
7022
+ "tonbo machine kill": {
7023
+ about: [
7024
+ "Ends a background process: SIGTERM to everything it started, then a kill 5 seconds later. It never wakes the Machine."
7025
+ ],
7026
+ examples: ["tonbo machine kill web 019a1f2e5a6b7c8d9e0f1a2b3c4d5e6f"],
7027
+ arguments: { name: "the Machine's name", process: "the process id `exec --detach` printed" }
7028
+ },
5944
7029
  "tonbo machine file read": {
5945
7030
  about: [
5946
7031
  "Prints one regular file of the Machine, waking it when it is asleep. A file is gone when the Machine sleeps, so a read that woke it sees only its image."
@@ -5979,6 +7064,30 @@ var COMMAND_HELP = {
5979
7064
  examples: ["tonbo machine file delete web /app/tmp --recursive"],
5980
7065
  arguments: { name: "the Machine's name", path: "the absolute path in the Machine" }
5981
7066
  },
7067
+ "tonbo machine hold set": {
7068
+ about: [
7069
+ "Keeps the Machine awake until at least now plus `--for` (1 hour by default, at most 1 hour). Running it again only moves the end later, so a script can repeat it while its work runs. It never wakes an asleep Machine, and it ends when the Machine sleeps, stops or runs out of credit or budget. An awake Machine is billed as usual. A program inside the Machine sets its own holds on `/run/tonbo-hold.sock`, with no key."
7070
+ ],
7071
+ examples: ["tonbo machine hold set web build --for 30m"],
7072
+ arguments: {
7073
+ name: "the Machine's name",
7074
+ hold: "a name you choose for the hold, such as build"
7075
+ }
7076
+ },
7077
+ "tonbo machine hold rm": {
7078
+ about: [
7079
+ "Releases one hold set from outside the Machine, whether or not it existed. The Machine sleeps `sleep_after` seconds later unless something else keeps it awake."
7080
+ ],
7081
+ examples: ["tonbo machine hold rm web build"],
7082
+ arguments: { name: "the Machine's name", hold: "the hold's name" }
7083
+ },
7084
+ "tonbo machine hold ls": {
7085
+ about: [
7086
+ "Lists the holds of the Machine's current activation, those set from outside and those a program inside set, with when each lapses. An asleep Machine has none."
7087
+ ],
7088
+ examples: ["tonbo machine hold ls web"],
7089
+ arguments: { name: "the Machine's name" }
7090
+ },
5982
7091
  "tonbo machine sleep": {
5983
7092
  about: [
5984
7093
  "Puts an awake Machine to sleep now, and says so when it is already asleep or still creating; its next request boots it on its latest spec. With `--wait` the CLI reads the Machine until it is asleep or `--timeout` passes."
@@ -5987,6 +7096,14 @@ var COMMAND_HELP = {
5987
7096
  arguments: NAME("Machine"),
5988
7097
  defaults: { "--timeout": String(DEFAULT_WAIT_SECONDS) }
5989
7098
  },
7099
+ "tonbo machine wake": {
7100
+ about: [
7101
+ "Wakes an asleep Machine now, without a request to it, and says so when it is already awake. The wake passes the same checks as any other and is billed the same: it needs credit, room on a host and a budget that allows it, and a refused wake says why. With `--wait` the CLI reads the Machine until it is awake or `--timeout` passes."
7102
+ ],
7103
+ examples: ["tonbo machine wake web --wait"],
7104
+ arguments: NAME("Machine"),
7105
+ defaults: { "--timeout": String(DEFAULT_WAIT_SECONDS) }
7106
+ },
5990
7107
  "tonbo machine delete": {
5991
7108
  about: [
5992
7109
  "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.",
@@ -6137,19 +7254,19 @@ function optionDescription(option) {
6137
7254
  function applyHelp(program) {
6138
7255
  const named = /* @__PURE__ */ new Set();
6139
7256
  const walk = (command, words) => {
6140
- const path4 = words.join(" ");
7257
+ const path5 = words.join(" ");
6141
7258
  command.configureHelp({ ...command.configureHelp(), optionDescription });
6142
- const help = COMMAND_HELP[path4];
7259
+ const help = COMMAND_HELP[path5];
6143
7260
  if (help) {
6144
- named.add(path4);
7261
+ named.add(path5);
6145
7262
  for (const [name, text] of Object.entries(help.arguments ?? {})) {
6146
7263
  const argument = command.registeredArguments.find((entry) => entry.name() === name);
6147
- if (!argument) throw new Error(`${path4} has no argument ${name}`);
7264
+ if (!argument) throw new Error(`${path5} has no argument ${name}`);
6148
7265
  argument.description = text;
6149
7266
  }
6150
7267
  for (const [flag, text] of Object.entries(help.defaults ?? {})) {
6151
7268
  const option = command.options.find((entry) => entry.long === flag);
6152
- if (!option) throw new Error(`${path4} has no option ${flag}`);
7269
+ if (!option) throw new Error(`${path5} has no option ${flag}`);
6153
7270
  option.defaultValueDescription = text;
6154
7271
  }
6155
7272
  command.addHelpText("after", commandHelpText(help));
@@ -6157,15 +7274,15 @@ function applyHelp(program) {
6157
7274
  for (const child of command.commands) walk(child, [...words, child.name()]);
6158
7275
  };
6159
7276
  walk(program, [program.name()]);
6160
- for (const path4 of Object.keys(COMMAND_HELP))
6161
- if (!named.has(path4)) throw new Error(`The help of ${path4} names no command`);
7277
+ for (const path5 of Object.keys(COMMAND_HELP))
7278
+ if (!named.has(path5)) throw new Error(`The help of ${path5} names no command`);
6162
7279
  program.addHelpText("after", programHelpText());
6163
7280
  return program;
6164
7281
  }
6165
7282
 
6166
7283
  // src/machine-cell.ts
6167
- import { readFile as readFile3, writeFile } from "node:fs/promises";
6168
- import { randomUUID as randomUUID6 } from "node:crypto";
7284
+ import { readFile as readFile4, writeFile } from "node:fs/promises";
7285
+ import { randomUUID as randomUUID8 } from "node:crypto";
6169
7286
  var EXEC_EXIT_CODES = {
6170
7287
  timeout: 124,
6171
7288
  output_limit: 125,
@@ -6318,13 +7435,198 @@ async function machineExecCommand(deps, name, command, options = {}) {
6318
7435
  deps.notice(`The command ended: ${ended.reason}.`);
6319
7436
  exitWith(deps, execExitCode(ended));
6320
7437
  }
6321
- async function machineFileReadCommand(deps, name, path4, options = {}) {
7438
+ function processEndText(process2) {
7439
+ const end = process2.end;
7440
+ if (!end) return "-";
7441
+ if (end.reason === "exited") return `exited ${end.exit_code ?? 0}`;
7442
+ if (end.reason === "signaled") return `signaled ${end.signal ?? ""}`.trim();
7443
+ return end.reason;
7444
+ }
7445
+ function processExitCode(process2) {
7446
+ const end = process2.end;
7447
+ if (!end) return 0;
7448
+ if (end.reason === "exited" || end.reason === "signaled" || end.reason === "timeout")
7449
+ return execExitCode({ reason: end.reason, exit_code: end.exit_code, signal: end.signal });
7450
+ return end.reason === "lost" ? EXEC_EXIT_CODES.lost : EXEC_EXIT_CODES.killed;
7451
+ }
7452
+ async function machineProcessStartCommand(deps, name, command, options = {}) {
7453
+ if (command.length === 0)
7454
+ throw new Error(
7455
+ "Name the command after --, such as `tonbo machine exec web --detach -- npm run build`."
7456
+ );
7457
+ const body = { command };
7458
+ if (options.cwd) body.cwd = options.cwd;
7459
+ if (options.env?.length) body.env = parseEnv2(options.env);
7460
+ if (options.timeout !== void 0) body.timeout_seconds = options.timeout;
7461
+ if (options.expectActivation) body.expect_activation = options.expectActivation;
7462
+ try {
7463
+ const key = await deps.auth.apiKey();
7464
+ const region = await regionOf(deps, key, name);
7465
+ if (options.stdin) body.stdin = Buffer.from(await readAllStdin(deps)).toString("base64");
7466
+ const response = await cellApi(deps).fetch("startMachineProcess", key, region, {
7467
+ params: { name },
7468
+ headers: {
7469
+ "content-type": "application/json",
7470
+ "idempotency-key": options.idempotencyKey ?? randomUUID8()
7471
+ },
7472
+ body: JSON.stringify(body)
7473
+ });
7474
+ const { data } = await response.json();
7475
+ if (response.headers.get("tonbo-woke") === "true") {
7476
+ const ceiling = response.headers.get("tonbo-ceiling-usd-micros-per-hour");
7477
+ noticeWake(
7478
+ deps,
7479
+ {
7480
+ activation: data.activation,
7481
+ woke: true,
7482
+ ceiling_usd_micros_per_hour: ceiling === null ? null : Number(ceiling)
7483
+ },
7484
+ name
7485
+ );
7486
+ }
7487
+ print(
7488
+ deps,
7489
+ options.json,
7490
+ data,
7491
+ response.status === 200 ? `${data.process} (already started under this key, ${data.state})` : data.process
7492
+ );
7493
+ if (!options.json && response.status !== 200)
7494
+ deps.notice(
7495
+ `Started; it keeps ${name} awake until it ends or at ${data.deadline}. Read it with \`tonbo machine output ${name} ${data.process}\`, wait for it with \`tonbo machine wait ${name} ${data.process}\`.`
7496
+ );
7497
+ } catch (error) {
7498
+ throw explainMachineError(error);
7499
+ }
7500
+ }
7501
+ async function readProcess(deps, key, region, name, id) {
7502
+ return cellApi(deps).json("getMachineProcess", key, region, {
7503
+ params: { name, process: id }
7504
+ });
7505
+ }
7506
+ function processFields(process2) {
7507
+ return fields([
7508
+ ["process", process2.process],
7509
+ ["state", process2.state],
7510
+ ["command", (process2.argv_preview ?? []).join(" ") || "-"],
7511
+ ["started", process2.started_at],
7512
+ ["deadline", process2.deadline],
7513
+ ["ended", process2.ended_at ?? "-"],
7514
+ ["end", processEndText(process2)],
7515
+ ["activation", process2.activation]
7516
+ ]);
7517
+ }
7518
+ async function machineProcessListCommand(deps, name, options = {}) {
7519
+ try {
7520
+ const key = await deps.auth.apiKey();
7521
+ const region = await regionOf(deps, key, name);
7522
+ const processes = await cellApi(deps).json(
7523
+ "listMachineProcesses",
7524
+ key,
7525
+ region,
7526
+ {
7527
+ params: { name }
7528
+ }
7529
+ );
7530
+ print(
7531
+ deps,
7532
+ options.json,
7533
+ processes,
7534
+ processes.length === 0 ? `${name} has no background processes.` : table(
7535
+ ["PROCESS", "STATE", "STARTED", "END", "COMMAND"],
7536
+ processes.map((process2) => [
7537
+ process2.process,
7538
+ process2.state,
7539
+ process2.started_at,
7540
+ processEndText(process2),
7541
+ (process2.argv_preview ?? []).join(" ")
7542
+ ])
7543
+ )
7544
+ );
7545
+ } catch (error) {
7546
+ throw explainMachineError(error);
7547
+ }
7548
+ }
7549
+ async function machineProcessWaitCommand(deps, name, id, options = {}) {
7550
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
7551
+ try {
7552
+ const key = await deps.auth.apiKey();
7553
+ const region = await regionOf(deps, key, name);
7554
+ let process2 = await readProcess(deps, key, region, name, id);
7555
+ while (process2.state === "running") {
7556
+ await sleep(2e3);
7557
+ process2 = await readProcess(deps, key, region, name, id);
7558
+ }
7559
+ print(deps, options.json, process2, processFields(process2));
7560
+ exitWith(deps, processExitCode(process2));
7561
+ } catch (error) {
7562
+ throw explainMachineError(error);
7563
+ }
7564
+ }
7565
+ async function machineProcessOutputCommand(deps, name, id, options = {}) {
7566
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
7567
+ try {
7568
+ const key = await deps.auth.apiKey();
7569
+ const region = await regionOf(deps, key, name);
7570
+ let offset = options.offset ?? 0;
7571
+ for (; ; ) {
7572
+ const response = await cellApi(deps).fetch("readMachineProcessOutput", key, region, {
7573
+ params: { name, process: id },
7574
+ query: { offset: String(offset) }
7575
+ });
7576
+ const bytes2 = new Uint8Array(await response.arrayBuffer());
7577
+ const start = Number(response.headers.get("tonbo-output-start") ?? offset);
7578
+ if (start > offset)
7579
+ deps.notice(`(${start - offset} bytes before offset ${start} were dropped from the ring)`);
7580
+ if (bytes2.length) writeBytes(deps, "stdout", bytes2);
7581
+ offset = Number(response.headers.get("tonbo-output-next-offset") ?? start + bytes2.length);
7582
+ const ended = response.headers.get("tonbo-process-state") === "ended";
7583
+ if (start + bytes2.length < offset) continue;
7584
+ if (!options.follow || ended) break;
7585
+ await sleep(1e3);
7586
+ }
7587
+ } catch (error) {
7588
+ throw explainMachineError(error);
7589
+ }
7590
+ }
7591
+ async function machineProcessKillCommand(deps, name, id, options = {}) {
7592
+ try {
7593
+ const key = await deps.auth.apiKey();
7594
+ const region = await regionOf(deps, key, name);
7595
+ const process2 = await cellApi(deps).json("killMachineProcess", key, region, {
7596
+ params: { name, process: id }
7597
+ });
7598
+ print(
7599
+ deps,
7600
+ options.json,
7601
+ process2,
7602
+ process2.state === "ended" ? `${id} had already ended: ${processEndText(process2)}.` : `Killing ${id}; it ends within a few seconds.`
7603
+ );
7604
+ } catch (error) {
7605
+ throw explainMachineError(error);
7606
+ }
7607
+ }
7608
+ async function machineProcessSummary(cell, key, region, name) {
7609
+ try {
7610
+ const processes = await cell.json("listMachineProcesses", key, region, {
7611
+ params: { name }
7612
+ });
7613
+ const line = (process2) => `${process2.process} ${(process2.argv_preview ?? []).join(" ")} (${process2.state === "running" ? `until ${process2.deadline}` : processEndText(process2)})`;
7614
+ const last = processes.find((process2) => process2.state === "ended");
7615
+ return {
7616
+ running: processes.filter((process2) => process2.state === "running").map(line),
7617
+ last: last ? line(last) : null
7618
+ };
7619
+ } catch {
7620
+ return null;
7621
+ }
7622
+ }
7623
+ async function machineFileReadCommand(deps, name, path5, options = {}) {
6322
7624
  try {
6323
7625
  const key = await deps.auth.apiKey();
6324
7626
  const region = await regionOf(deps, key, name);
6325
7627
  const response = await cellApi(deps).fetch("readMachineFile", key, region, {
6326
7628
  params: { name },
6327
- query: { path: path4 },
7629
+ query: { path: path5 },
6328
7630
  headers: { range: options.range, "tonbo-expect-activation": options.expectActivation }
6329
7631
  });
6330
7632
  const bytes2 = new Uint8Array(await response.arrayBuffer());
@@ -6334,21 +7636,21 @@ async function machineFileReadCommand(deps, name, path4, options = {}) {
6334
7636
  throw explainMachineError(error);
6335
7637
  }
6336
7638
  }
6337
- async function machineFileWriteCommand(deps, name, path4, options = {}) {
7639
+ async function machineFileWriteCommand(deps, name, path5, options = {}) {
6338
7640
  try {
6339
- const bytes2 = options.from ? new Uint8Array(await readFile3(options.from)) : await readAllStdin(deps);
7641
+ const bytes2 = options.from ? new Uint8Array(await readFile4(options.from)) : await readAllStdin(deps);
6340
7642
  const key = await deps.auth.apiKey();
6341
7643
  const region = await regionOf(deps, key, name);
6342
7644
  const written = await cellApi(deps).json("writeMachineFile", key, region, {
6343
7645
  params: { name },
6344
- query: { path: path4 },
7646
+ query: { path: path5 },
6345
7647
  headers: {
6346
7648
  "content-type": "application/octet-stream",
6347
7649
  "if-match": options.ifMatch,
6348
7650
  "if-none-match": options.create ? "*" : void 0,
6349
7651
  "tonbo-file-mode": options.mode,
6350
7652
  "tonbo-expect-activation": options.expectActivation,
6351
- "idempotency-key": randomUUID6()
7653
+ "idempotency-key": randomUUID8()
6352
7654
  },
6353
7655
  body: new Uint8Array(bytes2)
6354
7656
  });
@@ -6363,28 +7665,28 @@ async function machineFileWriteCommand(deps, name, path4, options = {}) {
6363
7665
  throw explainMachineError(error);
6364
7666
  }
6365
7667
  }
6366
- async function machineFileStatCommand(deps, name, path4, options = {}) {
7668
+ async function machineFileStatCommand(deps, name, path5, options = {}) {
6367
7669
  try {
6368
7670
  const key = await deps.auth.apiKey();
6369
7671
  const region = await regionOf(deps, key, name);
6370
- const stat = await cellApi(deps).json(
7672
+ const stat2 = await cellApi(deps).json(
6371
7673
  "statMachineFile",
6372
7674
  key,
6373
7675
  region,
6374
7676
  {
6375
7677
  params: { name },
6376
- query: { path: path4 },
7678
+ query: { path: path5 },
6377
7679
  headers: { "tonbo-expect-activation": options.expectActivation }
6378
7680
  }
6379
7681
  );
6380
- noticeWake(deps, stat.machine, name);
7682
+ noticeWake(deps, stat2.machine, name);
6381
7683
  print(
6382
7684
  deps,
6383
7685
  options.json,
6384
- stat,
7686
+ stat2,
6385
7687
  fields(
6386
7688
  ["path", "type", "size", "mode", "uid", "gid", "mtime", "etag", "target", "durability"].map(
6387
- (field) => [field, cellText(stat[field])]
7689
+ (field) => [field, cellText(stat2[field])]
6388
7690
  )
6389
7691
  )
6390
7692
  );
@@ -6392,13 +7694,13 @@ async function machineFileStatCommand(deps, name, path4, options = {}) {
6392
7694
  throw explainMachineError(error);
6393
7695
  }
6394
7696
  }
6395
- async function machineFileListCommand(deps, name, path4, options = {}) {
7697
+ async function machineFileListCommand(deps, name, path5, options = {}) {
6396
7698
  try {
6397
7699
  const key = await deps.auth.apiKey();
6398
7700
  const region = await regionOf(deps, key, name);
6399
7701
  const listing = await cellApi(deps).json("listMachineFiles", key, region, {
6400
7702
  params: { name },
6401
- query: { path: path4 },
7703
+ query: { path: path5 },
6402
7704
  headers: { "tonbo-expect-activation": options.expectActivation }
6403
7705
  });
6404
7706
  noticeWake(deps, listing.machine, name);
@@ -6422,19 +7724,89 @@ async function machineFileListCommand(deps, name, path4, options = {}) {
6422
7724
  throw explainMachineError(error);
6423
7725
  }
6424
7726
  }
6425
- async function machineFileDeleteCommand(deps, name, path4, options = {}) {
7727
+ async function machineFileDeleteCommand(deps, name, path5, options = {}) {
6426
7728
  try {
6427
7729
  const key = await deps.auth.apiKey();
6428
7730
  const region = await regionOf(deps, key, name);
6429
7731
  await cellApi(deps).fetch("deleteMachineFile", key, region, {
6430
7732
  params: { name },
6431
- query: { path: path4, recursive: options.recursive ? "true" : void 0 },
7733
+ query: { path: path5, recursive: options.recursive ? "true" : void 0 },
6432
7734
  headers: {
6433
7735
  "tonbo-expect-activation": options.expectActivation,
6434
- "idempotency-key": randomUUID6()
7736
+ "idempotency-key": randomUUID8()
6435
7737
  }
6436
7738
  });
6437
- print(deps, options.json, { path: path4 }, `Deleted ${path4}.`);
7739
+ print(deps, options.json, { path: path5 }, `Deleted ${path5}.`);
7740
+ } catch (error) {
7741
+ throw explainMachineError(error);
7742
+ }
7743
+ }
7744
+ var HOLD_MAX_SECONDS = 3600;
7745
+ function parseHoldDuration(value) {
7746
+ const match = /^(\d+)\s*([smh]?)$/.exec(value.trim());
7747
+ const unit = { "": 1, s: 1, m: 60, h: 3600 }[match?.[2] ?? ""] ?? 1;
7748
+ const seconds = match ? Number(match[1]) * unit : Number.NaN;
7749
+ if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > HOLD_MAX_SECONDS)
7750
+ throw new Error(`--for ${value} is not between 1 second and 1 hour, such as 90s, 45m or 1h.`);
7751
+ return seconds;
7752
+ }
7753
+ async function machineHoldSetCommand(deps, name, hold, options = {}) {
7754
+ try {
7755
+ const key = await deps.auth.apiKey();
7756
+ const region = await regionOf(deps, key, name);
7757
+ const placed = await cellApi(deps).json("setMachineHold", key, region, {
7758
+ params: { name, hold },
7759
+ headers: {
7760
+ "content-type": "application/json",
7761
+ "tonbo-expect-activation": options.expectActivation
7762
+ },
7763
+ body: JSON.stringify({ seconds: options.for ?? HOLD_MAX_SECONDS })
7764
+ });
7765
+ print(
7766
+ deps,
7767
+ options.json,
7768
+ placed,
7769
+ `${name} stays awake until ${placed.until} (hold ${placed.hold}). Run this again before then to extend it; \`tonbo machine hold rm ${name} ${placed.hold}\` lets it sleep. An awake Machine is billed as usual.`
7770
+ );
7771
+ } catch (error) {
7772
+ throw explainMachineError(error);
7773
+ }
7774
+ }
7775
+ async function machineHoldRmCommand(deps, name, hold, options = {}) {
7776
+ try {
7777
+ const key = await deps.auth.apiKey();
7778
+ const region = await regionOf(deps, key, name);
7779
+ await cellApi(deps).fetch("releaseMachineHold", key, region, { params: { name, hold } });
7780
+ print(
7781
+ deps,
7782
+ options.json,
7783
+ { hold },
7784
+ `Released ${hold}. ${name} sleeps once nothing else keeps it awake.`
7785
+ );
7786
+ } catch (error) {
7787
+ throw explainMachineError(error);
7788
+ }
7789
+ }
7790
+ async function machineHoldLsCommand(deps, name, options = {}) {
7791
+ try {
7792
+ const key = await deps.auth.apiKey();
7793
+ const region = await regionOf(deps, key, name);
7794
+ const holds = await cellApi(deps).json("listMachineHolds", key, region, {
7795
+ params: { name }
7796
+ });
7797
+ print(
7798
+ deps,
7799
+ options.json,
7800
+ holds,
7801
+ holds.length === 0 ? `${name} has no holds.` : table(
7802
+ ["HOLD", "SET FROM", "UNTIL"],
7803
+ holds.map((entry) => [
7804
+ entry.hold,
7805
+ entry.source === "guest" ? "inside the Machine" : "outside (API or CLI)",
7806
+ entry.until
7807
+ ])
7808
+ )
7809
+ );
6438
7810
  } catch (error) {
6439
7811
  throw explainMachineError(error);
6440
7812
  }
@@ -6467,8 +7839,8 @@ async function terminalAsk(question) {
6467
7839
 
6468
7840
  // src/ssh.ts
6469
7841
  import { spawn, spawnSync } from "node:child_process";
6470
- import { existsSync } from "node:fs";
6471
- import { chmod as chmod2, mkdir as mkdir3, readFile as readFile4, readdir, rename as rename3, writeFile as writeFile2 } from "node:fs/promises";
7842
+ import { existsSync as existsSync2 } from "node:fs";
7843
+ import { chmod as chmod2, mkdir as mkdir4, readFile as readFile5, readdir, rename as rename4, writeFile as writeFile2 } from "node:fs/promises";
6472
7844
  import { homedir } from "node:os";
6473
7845
  import { join } from "node:path";
6474
7846
  function sshDirectory(env = process.env) {
@@ -6492,9 +7864,9 @@ function cellOrigin2(host) {
6492
7864
  }
6493
7865
  async function ensureKey(directory) {
6494
7866
  const key = join(directory, "id_ed25519");
6495
- await mkdir3(directory, { recursive: true, mode: 448 });
7867
+ await mkdir4(directory, { recursive: true, mode: 448 });
6496
7868
  await chmod2(directory, 448);
6497
- if (!existsSync(key)) {
7869
+ if (!existsSync2(key)) {
6498
7870
  const made = spawnSync(
6499
7871
  "ssh-keygen",
6500
7872
  ["-q", "-t", "ed25519", "-N", "", "-C", "tonbo", "-f", key],
@@ -6510,10 +7882,10 @@ async function ensureKey(directory) {
6510
7882
  }
6511
7883
  return key;
6512
7884
  }
6513
- async function writeAtomic(path4, data) {
6514
- const temporary = `${path4}.${process.pid}.tmp`;
7885
+ async function writeAtomic(path5, data) {
7886
+ const temporary = `${path5}.${process.pid}.tmp`;
6515
7887
  await writeFile2(temporary, data, { mode: 384 });
6516
- await rename3(temporary, path4);
7888
+ await rename4(temporary, path5);
6517
7889
  }
6518
7890
  function certificatePath(directory, user) {
6519
7891
  return join(directory, `${user}-cert.pub`);
@@ -6526,7 +7898,7 @@ function hostCAPath(directory, host) {
6526
7898
  }
6527
7899
  async function trustHostCA(directory, host, lines) {
6528
7900
  const cells = join(directory, "host-ca");
6529
- await mkdir3(cells, { recursive: true, mode: 448 });
7901
+ await mkdir4(cells, { recursive: true, mode: 448 });
6530
7902
  await writeAtomic(hostCAPath(directory, host), `${lines.join("\n")}
6531
7903
  `);
6532
7904
  const trusted = /* @__PURE__ */ new Set();
@@ -6534,7 +7906,7 @@ async function trustHostCA(directory, host, lines) {
6534
7906
  if (name.endsWith(".tmp")) {
6535
7907
  continue;
6536
7908
  }
6537
- for (const line of (await readFile4(join(cells, name), "utf8")).split("\n")) {
7909
+ for (const line of (await readFile5(join(cells, name), "utf8")).split("\n")) {
6538
7910
  if (line.trim() !== "") {
6539
7911
  trusted.add(line.trim());
6540
7912
  }
@@ -6546,7 +7918,7 @@ async function trustHostCA(directory, host, lines) {
6546
7918
  async function heldUntil(directory, user) {
6547
7919
  try {
6548
7920
  const sidecar = JSON.parse(
6549
- await readFile4(`${certificatePath(directory, user)}.json`, "utf8")
7921
+ await readFile5(`${certificatePath(directory, user)}.json`, "utf8")
6550
7922
  );
6551
7923
  return sidecar.valid_before ? Date.parse(sidecar.valid_before) : 0;
6552
7924
  } catch {
@@ -6565,11 +7937,9 @@ async function cellRequest(bearer, method, url, body) {
6565
7937
  },
6566
7938
  body: body === void 0 ? void 0 : JSON.stringify(body)
6567
7939
  });
6568
- if (response.status === 401 && attempt === 0 && isAccountBearer(bearer)) {
6569
- bearer.invalidate(MANAGEMENT_AUDIENCE);
6570
- continue;
6571
- }
6572
7940
  const answer = await response.json().catch(() => null);
7941
+ if (response.status === 401 && attempt === 0 && retriesAfterRefusal(bearer, MANAGEMENT_AUDIENCE, answer?.code ?? null))
7942
+ continue;
6573
7943
  if (!response.ok) {
6574
7944
  throw new Error(
6575
7945
  `${url} answered ${response.status}${answer?.code ? ` ${answer.code}` : ""}${answer?.detail ? `: ${answer.detail}` : ""}`
@@ -6582,11 +7952,11 @@ async function refreshCertificate(bearer, destination, now = Date.now, directory
6582
7952
  const key = await ensureKey(directory);
6583
7953
  const certificate = certificatePath(directory, destination.user);
6584
7954
  const knownHosts = join(directory, "known_hosts");
6585
- if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync(hostCAPath(directory, destination.host)) && existsSync(knownHosts)) {
7955
+ if (await heldUntil(directory, destination.user) - now() > RENEW_BEFORE_MS && existsSync2(hostCAPath(directory, destination.host)) && existsSync2(knownHosts)) {
6586
7956
  return { key, certificate, knownHosts };
6587
7957
  }
6588
7958
  const origin = cellOrigin2(destination.host);
6589
- const publicKey = (await readFile4(`${key}.pub`, "utf8")).trim();
7959
+ const publicKey = (await readFile5(`${key}.pub`, "utf8")).trim();
6590
7960
  const issued = await cellRequest(bearer, "POST", `${origin}/v1/ssh/certificates`, {
6591
7961
  public_key: publicKey,
6592
7962
  ttl_seconds: REQUESTED_TTL_SECONDS
@@ -6678,7 +8048,7 @@ async function sshHostCaCommand(deps, options) {
6678
8048
  print(deps, options.json, data, data.known_hosts.join("\n"));
6679
8049
  }
6680
8050
  async function sshCertificateIssueCommand(deps, options) {
6681
- const publicKey = (await readFile4(options.publicKey, "utf8")).trim();
8051
+ const publicKey = (await readFile5(options.publicKey, "utf8")).trim();
6682
8052
  const key = await deps.auth.apiKey();
6683
8053
  const data = await (deps.cell ?? new CellApi(fetch)).json(
6684
8054
  "issueSshCertificate",
@@ -6709,8 +8079,9 @@ function createDependencies(selection = {}) {
6709
8079
  loginOrigin: loginOrigin(api),
6710
8080
  account
6711
8081
  });
8082
+ const notice = (text) => console.error(text);
6712
8083
  return {
6713
- api: new ManagementApi(fetch, api),
8084
+ api: new ManagementApi(noticingBudget(fetch, notice), api),
6714
8085
  ask: terminalAsk,
6715
8086
  auth: new AuthClient(
6716
8087
  credentials,
@@ -6726,7 +8097,9 @@ function createDependencies(selection = {}) {
6726
8097
  credentials,
6727
8098
  interactive: () => process.stdin.isTTY === true && process.stderr.isTTY === true,
6728
8099
  output: (text) => console.log(text),
6729
- notice: (text) => console.error(text),
8100
+ notice,
8101
+ openUrl: openBrowser,
8102
+ processes: (key, region, name) => machineProcessSummary(new CellApi(fetch), key, region, name),
6730
8103
  progress: new TerminalProgress(process.stderr),
6731
8104
  select: terminalSelect
6732
8105
  };
@@ -6740,7 +8113,7 @@ function parseWhole(value) {
6740
8113
  return Number(value);
6741
8114
  }
6742
8115
  function operationOptions(command, operationId) {
6743
- const query = managementOperations[operationId].query;
8116
+ const query = managementOperation(operationId).query;
6744
8117
  if (query.includes("limit"))
6745
8118
  command.option("--limit <count>", "the most items to return", parseLimit);
6746
8119
  if (query.includes("cursor"))
@@ -6749,6 +8122,10 @@ function operationOptions(command, operationId) {
6749
8122
  }
6750
8123
  var ifMatchHelp = (action) => `${action} only the Machine at the ETag \`tonbo machine show\` printed (with or without its quotes)`;
6751
8124
  var repeated = (value, previous = []) => [...previous, value];
8125
+ var budgetOption = (whom) => new Option(
8126
+ "--budget <budget>",
8127
+ `put ${whom} under a monthly budget: an amount in dollars such as 50 creates one, a budget's name adds to it`
8128
+ );
6752
8129
  var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
6753
8130
  function machineSpecOptions(command, update) {
6754
8131
  const later = update ? "; applies at the next boot" : "";
@@ -6814,7 +8191,9 @@ function createProgram(makeDependencies = createDependencies) {
6814
8191
  const account2 = program.opts().account;
6815
8192
  await loginCommand(dependencies(), { ...options, account: account2 });
6816
8193
  });
6817
- program.command("logout").description("end this computer's CLI login and remove the saved login").action(async () => logoutCommand(dependencies()));
8194
+ program.command("logout").description(
8195
+ "end this computer's CLI login and remove the saved login; Yield's capture here stops until the next login"
8196
+ ).action(async () => logoutCommand(dependencies()));
6818
8197
  const account = program.command("account").description("the accounts you belong to and the one commands act in");
6819
8198
  operationOptions(
6820
8199
  account.command("show").description("show the account, your role in it and its limits"),
@@ -6856,7 +8235,7 @@ function createProgram(makeDependencies = createDependencies) {
6856
8235
  ).option(
6857
8236
  "--idempotency-key <key>",
6858
8237
  "reuse the key of an unconfirmed creation to get the Agent it created"
6859
- ),
8238
+ ).addOption(budgetOption("the Agent")),
6860
8239
  "createAgent"
6861
8240
  ).action(
6862
8241
  async (name, options) => agentCreateCommand(dependencies(), name, options)
@@ -6891,7 +8270,7 @@ function createProgram(makeDependencies = createDependencies) {
6891
8270
  repeated
6892
8271
  ).addOption(
6893
8272
  new Option("--expires-in <period>", "when the key expires").choices(apiKeyExpirations)
6894
- ),
8273
+ ).addOption(budgetOption("the key's member")),
6895
8274
  "createApiKey"
6896
8275
  ).action(
6897
8276
  async (name, options) => apiKeyCreateCommand(dependencies(), name, options)
@@ -6905,6 +8284,37 @@ function createProgram(makeDependencies = createDependencies) {
6905
8284
  ).action(
6906
8285
  async (keyId, options) => apiKeyRevokeCommand(dependencies(), keyId, options)
6907
8286
  );
8287
+ const budget = program.command("budget").description("the account's monthly spend budgets");
8288
+ operationOptions(
8289
+ budget.command("list").description("list the budgets, the account budget first"),
8290
+ "listBudgets"
8291
+ ).action(async (options) => budgetListCommand(dependencies(), options));
8292
+ operationOptions(
8293
+ budget.command("show <budget>").description("show one budget, by its name or id"),
8294
+ "getBudget"
8295
+ ).action(
8296
+ async (name, options) => budgetShowCommand(dependencies(), name, options)
8297
+ );
8298
+ operationOptions(
8299
+ budget.command("add <name>").description("create a budget for you or Agents you created").requiredOption("--limit <dollars>", "the monthly limit in dollars, such as 50").option(
8300
+ "--member <member>",
8301
+ "a member to cover: you, or an Agent you created (subject, id or name); repeat for several",
8302
+ repeated
8303
+ ),
8304
+ "createBudget"
8305
+ ).action(
8306
+ async (name, options) => budgetAddCommand(dependencies(), name, options)
8307
+ );
8308
+ operationOptions(
8309
+ budget.command("set <budget>").description("lower the budget that covers you, or add Agents you created to it").option("--limit <dollars>", "a lower monthly limit; a raise opens the console").option(
8310
+ "--add <member>",
8311
+ "an Agent you created to cover too (subject, id or name); repeat for several",
8312
+ repeated
8313
+ ),
8314
+ "changeBudget"
8315
+ ).action(
8316
+ async (name, options) => budgetSetCommand(dependencies(), name, options)
8317
+ );
6908
8318
  const bucket = program.command("bucket").description("the account's Streams buckets");
6909
8319
  operationOptions(bucket.command("list").description("list the buckets"), "listBuckets").action(
6910
8320
  async (options) => bucketListCommand(dependencies(), options)
@@ -7116,7 +8526,10 @@ function createProgram(makeDependencies = createDependencies) {
7116
8526
  machineSpecOptions(
7117
8527
  machine.command("update <name> [command...]").description(
7118
8528
  "change a Machine; [command...] after -- replaces its command at the next boot"
7119
- ).option("--image <reference>", "a new image, named as for create; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option("--if-match <etag>", ifMatchHelp("change")).option("--discard-snapshot", "apply a change that discards the Machine's snapshot").option("--no-volume", "remove the Machine's Volume at the next boot; the Volume is kept").option("--no-delete-after", "never delete the Machine for being asleep"),
8529
+ ).option("--image <reference>", "a new image, named as for create; applies at the next boot").option("--image-command", "run the image's own CMD again at the next boot").option("--unset-env <KEY>", "remove an environment variable; repeat for several", repeated).option("--clear-ports", "remove every port").option("--if-match <etag>", ifMatchHelp("change")).option("--discard-snapshot", "apply a change that discards the Machine's snapshot").option("--no-volume", "remove the Machine's Volume at the next boot; the Volume is kept").option(
8530
+ "--database <name>",
8531
+ "bind the account's database <name> of the Machine's region instead; applies at the next wake"
8532
+ ).option("--no-database", "bind no database from the next wake; the database is kept").option("--no-delete-after", "never delete the Machine for being asleep"),
7120
8533
  true
7121
8534
  ),
7122
8535
  "updateMachine"
@@ -7126,13 +8539,16 @@ function createProgram(makeDependencies = createDependencies) {
7126
8539
  operationOptions(
7127
8540
  machine.command("exec <name> [command...]").description(
7128
8541
  "run a command in a Machine, waking it first, and print its output; exits with its status"
8542
+ ).option(
8543
+ "--detach",
8544
+ "start it in the background and print its id at once; it keeps the Machine awake until it ends or its timeout"
7129
8545
  ).option("--cwd <path>", "the working directory, an absolute path").option(
7130
8546
  "--env <KEY=VALUE>",
7131
8547
  "set an environment variable for the command; repeat for several",
7132
8548
  repeated
7133
8549
  ).option(
7134
8550
  "--timeout <seconds>",
7135
- "kill the command after this many seconds (default 600, at most 3600)",
8551
+ "kill the command after this many seconds (default 600, at most 3600; with --detach default 3600, at most 21600)",
7136
8552
  parseWhole
7137
8553
  ).option("--stdin", "send this process's stdin to the command (at most 1 MiB)").option(
7138
8554
  "--expect-activation <activation>",
@@ -7143,38 +8559,85 @@ function createProgram(makeDependencies = createDependencies) {
7143
8559
  ),
7144
8560
  "execMachineCommand"
7145
8561
  ).action(
7146
- async (name, command, options) => machineExecCommand(dependencies(), name, command, options)
8562
+ async (name, command, options) => options.detach ? machineProcessStartCommand(dependencies(), name, command, options) : machineExecCommand(dependencies(), name, command, options)
8563
+ );
8564
+ operationOptions(
8565
+ machine.command("ps <name>").description("list a Machine's background processes, running and recently ended"),
8566
+ "listMachineProcesses"
8567
+ ).action(
8568
+ async (name, options) => machineProcessListCommand(dependencies(), name, options)
8569
+ );
8570
+ operationOptions(
8571
+ machine.command("wait <name> <process>").description("wait for a background process to end; exits with its status"),
8572
+ "getMachineProcess"
8573
+ ).action(
8574
+ async (name, id, options) => machineProcessWaitCommand(dependencies(), name, id, options)
8575
+ );
8576
+ operationOptions(
8577
+ machine.command("output <name> <process>").description("print a background process's output, stdout and stderr together").option("--offset <bytes>", "start at this byte offset", parseWhole).option("--follow", "keep printing new output until the process ends"),
8578
+ "readMachineProcessOutput"
8579
+ ).action(
8580
+ async (name, id, options) => machineProcessOutputCommand(dependencies(), name, id, options)
8581
+ );
8582
+ operationOptions(
8583
+ machine.command("kill <name> <process>").description("end a background process: SIGTERM, then a kill after 5 seconds"),
8584
+ "killMachineProcess"
8585
+ ).action(
8586
+ async (name, id, options) => machineProcessKillCommand(dependencies(), name, id, options)
7147
8587
  );
7148
8588
  const file = machine.command("file").description("read, write, stat, list and delete a Machine's files");
7149
8589
  operationOptions(
7150
8590
  file.command("read <name> <path>").description("print a file of a Machine, waking it first").option("--output <file>", "write the bytes to this local file instead of stdout").option("--range <range>", "one byte range, such as bytes=0-1023").option("--expect-activation <activation>", "read only in this activation"),
7151
8591
  "readMachineFile"
7152
8592
  ).action(
7153
- async (name, path4, options) => machineFileReadCommand(dependencies(), name, path4, options)
8593
+ async (name, path5, options) => machineFileReadCommand(dependencies(), name, path5, options)
7154
8594
  );
7155
8595
  operationOptions(
7156
8596
  file.command("write <name> <path>").description("replace a file of a Machine with stdin or --from, atomically").option("--from <file>", "the local file to send instead of stdin").option("--mode <mode>", "the file's mode, four octal digits such as 0755").option("--if-match <etag>", "replace only the version with this ETag").option("--create", "only create the file; refuse when it exists").option("--expect-activation <activation>", "write only in this activation"),
7157
8597
  "writeMachineFile"
7158
8598
  ).action(
7159
- async (name, path4, options) => machineFileWriteCommand(dependencies(), name, path4, options)
8599
+ async (name, path5, options) => machineFileWriteCommand(dependencies(), name, path5, options)
7160
8600
  );
7161
8601
  operationOptions(
7162
8602
  file.command("stat <name> <path>").description("show a path's type, size, mode, owner and ETag").option("--expect-activation <activation>", "stat only in this activation"),
7163
8603
  "statMachineFile"
7164
8604
  ).action(
7165
- async (name, path4, options) => machineFileStatCommand(dependencies(), name, path4, options)
8605
+ async (name, path5, options) => machineFileStatCommand(dependencies(), name, path5, options)
7166
8606
  );
7167
8607
  operationOptions(
7168
8608
  file.command("list <name> <path>").description("list a directory of a Machine").option("--expect-activation <activation>", "list only in this activation"),
7169
8609
  "listMachineFiles"
7170
8610
  ).action(
7171
- async (name, path4, options) => machineFileListCommand(dependencies(), name, path4, options)
8611
+ async (name, path5, options) => machineFileListCommand(dependencies(), name, path5, options)
7172
8612
  );
7173
8613
  operationOptions(
7174
8614
  file.command("delete <name> <path>").description("delete a file, a symbolic link or an empty directory of a Machine").option("--recursive", "delete a directory and everything in it").option("--expect-activation <activation>", "delete only in this activation"),
7175
8615
  "deleteMachineFile"
7176
8616
  ).action(
7177
- async (name, path4, options) => machineFileDeleteCommand(dependencies(), name, path4, options)
8617
+ async (name, path5, options) => machineFileDeleteCommand(dependencies(), name, path5, options)
8618
+ );
8619
+ const hold = machine.command("hold").description("keep a Machine awake for work that has no open connection");
8620
+ operationOptions(
8621
+ hold.command("set <name> <hold>").description("keep a Machine awake until a hold lapses; never wakes it").option(
8622
+ "--for <duration>",
8623
+ "how long from now, such as 90s, 45m or 1h (at most 1h)",
8624
+ parseHoldDuration
8625
+ ).option("--expect-activation <activation>", "hold only this activation"),
8626
+ "setMachineHold"
8627
+ ).action(
8628
+ async (name, holdName, options) => machineHoldSetCommand(dependencies(), name, holdName, options)
8629
+ );
8630
+ operationOptions(
8631
+ hold.command("rm <name> <hold>").description("release a hold set from outside the Machine"),
8632
+ "releaseMachineHold"
8633
+ ).action(
8634
+ async (name, holdName, options) => machineHoldRmCommand(dependencies(), name, holdName, options)
8635
+ );
8636
+ operationOptions(
8637
+ hold.command("ls <name>").description("list what holds a Machine awake"),
8638
+ "listMachineHolds"
8639
+ ).action(
8640
+ async (name, options) => machineHoldLsCommand(dependencies(), name, options)
7178
8641
  );
7179
8642
  operationOptions(
7180
8643
  machine.command("sleep <name>").description("put a Machine to sleep now; its next request boots it on its latest spec").option("--if-match <etag>", ifMatchHelp("put to sleep")).option("--wait", "wait until the Machine is asleep").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
@@ -7182,6 +8645,12 @@ function createProgram(makeDependencies = createDependencies) {
7182
8645
  ).action(
7183
8646
  async (name, options) => machineSleepCommand(dependencies(), name, options)
7184
8647
  );
8648
+ operationOptions(
8649
+ machine.command("wake <name>").description("wake an asleep Machine now, without a request to it").option("--if-match <etag>", ifMatchHelp("wake")).option("--wait", "wait until the Machine is awake").option("--timeout <seconds>", "how long --wait waits, in seconds", parseWhole),
8650
+ "wakeMachine"
8651
+ ).action(
8652
+ async (name, options) => machineWakeCommand(dependencies(), name, options)
8653
+ );
7185
8654
  operationOptions(
7186
8655
  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")),
7187
8656
  "deleteMachine"