@tonbo/cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -5
- package/dist/bin/tonbo.js +959 -281
- package/package.json +1 -1
package/dist/bin/tonbo.js
CHANGED
|
@@ -8,7 +8,7 @@ import { readFileSync } from "node:fs";
|
|
|
8
8
|
import { createHash, randomBytes } from "node:crypto";
|
|
9
9
|
import { createServer } from "node:http";
|
|
10
10
|
import { execFile } from "node:child_process";
|
|
11
|
-
import
|
|
11
|
+
import os2 from "node:os";
|
|
12
12
|
import { promisify } from "node:util";
|
|
13
13
|
|
|
14
14
|
// ../../packages/tonbo-cli-core/dist/src/callback-page.js
|
|
@@ -177,178 +177,11 @@ async function requestJson(fetcher, url, init = {}) {
|
|
|
177
177
|
throw new HttpError(requestId ? `${message} (request ${requestId})` : message, response.status, body, requestId);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
-
// ../../packages/tonbo-cli-core/dist/src/
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
186
|
-
return cleaned || "computer";
|
|
187
|
-
}
|
|
188
|
-
var AuthClient = class {
|
|
189
|
-
credentials;
|
|
190
|
-
fetcher;
|
|
191
|
-
loginOrigin;
|
|
192
|
-
browserOpener;
|
|
193
|
-
hostname;
|
|
194
|
-
loginHint;
|
|
195
|
-
constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
|
|
196
|
-
this.credentials = credentials;
|
|
197
|
-
this.fetcher = fetcher;
|
|
198
|
-
this.loginOrigin = loginOrigin2;
|
|
199
|
-
this.browserOpener = browserOpener;
|
|
200
|
-
this.hostname = hostname;
|
|
201
|
-
this.loginHint = loginHint;
|
|
202
|
-
}
|
|
203
|
-
/** The personal key requests are made with: TONBO_API_KEY, or the saved
|
|
204
|
-
* login while it has not expired. */
|
|
205
|
-
async apiKey() {
|
|
206
|
-
const injected = process.env.TONBO_API_KEY?.trim();
|
|
207
|
-
if (injected)
|
|
208
|
-
return injected;
|
|
209
|
-
const login = await this.credentials.load();
|
|
210
|
-
if (!login)
|
|
211
|
-
throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
|
|
212
|
-
if (login.expires_at && Date.parse(login.expires_at) <= Date.now())
|
|
213
|
-
throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
|
|
214
|
-
return login.key;
|
|
215
|
-
}
|
|
216
|
-
/**
|
|
217
|
-
* The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
|
|
218
|
-
* a free port, then the one-time code is redeemed for a personal key named
|
|
219
|
-
* "CLI on <hostname>", which replaces that host's earlier login key.
|
|
220
|
-
*/
|
|
221
|
-
async login(progress = () => {
|
|
222
|
-
}, account) {
|
|
223
|
-
const verifier = randomBytes(48).toString("base64url");
|
|
224
|
-
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
225
|
-
const state = randomBytes(24).toString("base64url");
|
|
226
|
-
const code = await this.listenForCode(state, (redirectUri) => {
|
|
227
|
-
const authorization = new URL("/cli/login", this.loginOrigin);
|
|
228
|
-
authorization.search = new URLSearchParams({
|
|
229
|
-
code_challenge: challenge,
|
|
230
|
-
code_challenge_method: "S256",
|
|
231
|
-
redirect_uri: redirectUri,
|
|
232
|
-
state,
|
|
233
|
-
hostname: this.hostname,
|
|
234
|
-
...account ? { account } : {}
|
|
235
|
-
}).toString();
|
|
236
|
-
return this.browserOpener(authorization.toString());
|
|
237
|
-
}, progress);
|
|
238
|
-
progress({ status: "started", step: "key-redemption" });
|
|
239
|
-
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
240
|
-
method: "POST",
|
|
241
|
-
headers: { "content-type": "application/json" },
|
|
242
|
-
body: JSON.stringify({ code, code_verifier: verifier })
|
|
243
|
-
});
|
|
244
|
-
progress({ status: "completed", step: "key-redemption" });
|
|
245
|
-
const login = {
|
|
246
|
-
version: 1,
|
|
247
|
-
account_id: redeemed.account_id,
|
|
248
|
-
key_id: redeemed.api_key.id,
|
|
249
|
-
key: redeemed.key,
|
|
250
|
-
expires_at: redeemed.api_key.expires_at
|
|
251
|
-
};
|
|
252
|
-
progress({ status: "started", step: "credential-store" });
|
|
253
|
-
await this.credentials.save(login);
|
|
254
|
-
progress({ status: "completed", step: "credential-store" });
|
|
255
|
-
return login;
|
|
256
|
-
}
|
|
257
|
-
listenForCode(expectedState, ready, progress) {
|
|
258
|
-
return new Promise((resolve, reject) => {
|
|
259
|
-
let settled = false;
|
|
260
|
-
const sockets = /* @__PURE__ */ new Set();
|
|
261
|
-
let authorizationStarted = false;
|
|
262
|
-
const startAuthorization = () => {
|
|
263
|
-
if (authorizationStarted)
|
|
264
|
-
return;
|
|
265
|
-
authorizationStarted = true;
|
|
266
|
-
progress({ status: "completed", step: "browser" });
|
|
267
|
-
progress({ status: "started", step: "browser-authorization" });
|
|
268
|
-
};
|
|
269
|
-
const finish = (result, completedResponseSocket) => {
|
|
270
|
-
if (settled)
|
|
271
|
-
return;
|
|
272
|
-
settled = true;
|
|
273
|
-
clearTimeout(timeout);
|
|
274
|
-
const complete = (closeError) => {
|
|
275
|
-
if (closeError)
|
|
276
|
-
reject(closeError);
|
|
277
|
-
else if ("code" in result) {
|
|
278
|
-
progress({ status: "completed", step: "callback-close" });
|
|
279
|
-
resolve(result.code);
|
|
280
|
-
} else
|
|
281
|
-
reject(result.error);
|
|
282
|
-
};
|
|
283
|
-
if ("code" in result)
|
|
284
|
-
progress({ status: "started", step: "callback-close" });
|
|
285
|
-
if (!server.listening) {
|
|
286
|
-
complete();
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
server.close(complete);
|
|
290
|
-
for (const socket of sockets) {
|
|
291
|
-
if (socket !== completedResponseSocket)
|
|
292
|
-
socket.destroy();
|
|
293
|
-
}
|
|
294
|
-
server.closeIdleConnections();
|
|
295
|
-
};
|
|
296
|
-
const timeout = setTimeout(() => {
|
|
297
|
-
finish({ error: new Error("Timed out waiting for browser login.") });
|
|
298
|
-
}, 5 * 60 * 1e3);
|
|
299
|
-
const server = createServer((request, response) => {
|
|
300
|
-
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
301
|
-
if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
|
|
302
|
-
respond(response, "invalid");
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
const denied = url.searchParams.get("error");
|
|
306
|
-
if (denied) {
|
|
307
|
-
const responseSocket2 = response.socket;
|
|
308
|
-
respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
const code = url.searchParams.get("code");
|
|
312
|
-
if (!code) {
|
|
313
|
-
respond(response, "invalid");
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
startAuthorization();
|
|
317
|
-
progress({ status: "completed", step: "browser-authorization" });
|
|
318
|
-
const responseSocket = response.socket;
|
|
319
|
-
respond(response, "complete", () => finish({ code }, responseSocket));
|
|
320
|
-
});
|
|
321
|
-
server.on("connection", (socket) => {
|
|
322
|
-
sockets.add(socket);
|
|
323
|
-
socket.once("close", () => sockets.delete(socket));
|
|
324
|
-
});
|
|
325
|
-
server.once("error", (error) => finish({ error }));
|
|
326
|
-
progress({ status: "started", step: "callback-server" });
|
|
327
|
-
server.listen(0, LOOPBACK_HOST, () => {
|
|
328
|
-
const { port } = server.address();
|
|
329
|
-
progress({ status: "completed", step: "callback-server" });
|
|
330
|
-
progress({ status: "started", step: "browser" });
|
|
331
|
-
void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
|
|
332
|
-
error: error instanceof Error ? error : new Error("Could not open the login browser.")
|
|
333
|
-
}));
|
|
334
|
-
});
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
};
|
|
338
|
-
function respond(response, outcome, finished) {
|
|
339
|
-
const page = callbackResponse(outcome);
|
|
340
|
-
if (finished)
|
|
341
|
-
response.once("finish", finished);
|
|
342
|
-
response.writeHead(page.status, { ...page.headers, connection: "close" });
|
|
343
|
-
response.end(page.body);
|
|
344
|
-
}
|
|
345
|
-
async function openBrowser(url) {
|
|
346
|
-
if (process.platform === "darwin")
|
|
347
|
-
return void await exec("open", [url]);
|
|
348
|
-
if (process.platform === "win32")
|
|
349
|
-
return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
|
|
350
|
-
return void await exec("xdg-open", [url]);
|
|
351
|
-
}
|
|
180
|
+
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
181
|
+
import { randomUUID } from "node:crypto";
|
|
182
|
+
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
183
|
+
import os from "node:os";
|
|
184
|
+
import path from "node:path";
|
|
352
185
|
|
|
353
186
|
// ../../packages/tonbo-cli-core/dist/src/generated/contracts.js
|
|
354
187
|
var agentNameContract = {
|
|
@@ -711,25 +544,538 @@ var managementOperations = {
|
|
|
711
544
|
"period",
|
|
712
545
|
"sort",
|
|
713
546
|
"prefix",
|
|
547
|
+
"names",
|
|
714
548
|
"cursor",
|
|
715
549
|
"limit"
|
|
716
550
|
],
|
|
717
551
|
"idempotencyKey": false,
|
|
718
552
|
"cli": "tonbo database usage"
|
|
553
|
+
},
|
|
554
|
+
"listMachines": {
|
|
555
|
+
"method": "GET",
|
|
556
|
+
"path": "/machines",
|
|
557
|
+
"pathParams": [],
|
|
558
|
+
"query": [
|
|
559
|
+
"cursor",
|
|
560
|
+
"limit"
|
|
561
|
+
],
|
|
562
|
+
"idempotencyKey": false,
|
|
563
|
+
"cli": "tonbo machine list"
|
|
564
|
+
},
|
|
565
|
+
"createMachine": {
|
|
566
|
+
"method": "POST",
|
|
567
|
+
"path": "/machines",
|
|
568
|
+
"pathParams": [],
|
|
569
|
+
"query": [],
|
|
570
|
+
"idempotencyKey": true,
|
|
571
|
+
"cli": "tonbo machine create"
|
|
572
|
+
},
|
|
573
|
+
"getMachine": {
|
|
574
|
+
"method": "GET",
|
|
575
|
+
"path": "/machines/{name}",
|
|
576
|
+
"pathParams": [
|
|
577
|
+
"name"
|
|
578
|
+
],
|
|
579
|
+
"query": [],
|
|
580
|
+
"idempotencyKey": false,
|
|
581
|
+
"cli": "tonbo machine show"
|
|
582
|
+
},
|
|
583
|
+
"updateMachine": {
|
|
584
|
+
"method": "PATCH",
|
|
585
|
+
"path": "/machines/{name}",
|
|
586
|
+
"pathParams": [
|
|
587
|
+
"name"
|
|
588
|
+
],
|
|
589
|
+
"query": [],
|
|
590
|
+
"idempotencyKey": false,
|
|
591
|
+
"cli": "tonbo machine update"
|
|
592
|
+
},
|
|
593
|
+
"deleteMachine": {
|
|
594
|
+
"method": "DELETE",
|
|
595
|
+
"path": "/machines/{name}",
|
|
596
|
+
"pathParams": [
|
|
597
|
+
"name"
|
|
598
|
+
],
|
|
599
|
+
"query": [],
|
|
600
|
+
"idempotencyKey": false,
|
|
601
|
+
"cli": "tonbo machine delete"
|
|
602
|
+
},
|
|
603
|
+
"sleepMachine": {
|
|
604
|
+
"method": "POST",
|
|
605
|
+
"path": "/machines/{name}/sleep",
|
|
606
|
+
"pathParams": [
|
|
607
|
+
"name"
|
|
608
|
+
],
|
|
609
|
+
"query": [],
|
|
610
|
+
"idempotencyKey": false,
|
|
611
|
+
"cli": "tonbo machine sleep"
|
|
612
|
+
},
|
|
613
|
+
"listMachineShapes": {
|
|
614
|
+
"method": "GET",
|
|
615
|
+
"path": "/machine-shapes",
|
|
616
|
+
"pathParams": [],
|
|
617
|
+
"query": [],
|
|
618
|
+
"idempotencyKey": false,
|
|
619
|
+
"cli": "tonbo machine shapes"
|
|
620
|
+
}
|
|
621
|
+
};
|
|
622
|
+
var accountRoles = [
|
|
623
|
+
"owner",
|
|
624
|
+
"admin",
|
|
625
|
+
"member"
|
|
626
|
+
];
|
|
627
|
+
var apiKeyExpirations = [
|
|
628
|
+
"15_days",
|
|
629
|
+
"1_month",
|
|
630
|
+
"6_months",
|
|
631
|
+
"1_year",
|
|
632
|
+
"never"
|
|
633
|
+
];
|
|
634
|
+
var credentialsContract = {
|
|
635
|
+
"version": 1,
|
|
636
|
+
"description": "The login `tonbo login` (and `yield login`) saves: a personal key of one account. @tonbo/cli-core writes and reads it; other readers, such as @tonbo/sqlite-ursula/tonbo, follow `reader`.",
|
|
637
|
+
"path": {
|
|
638
|
+
"description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
|
|
639
|
+
"base_env": "XDG_CONFIG_HOME",
|
|
640
|
+
"fallback_base": [
|
|
641
|
+
"$HOME",
|
|
642
|
+
".config"
|
|
643
|
+
],
|
|
644
|
+
"relative": [
|
|
645
|
+
"tonbo",
|
|
646
|
+
"credentials.json"
|
|
647
|
+
]
|
|
648
|
+
},
|
|
649
|
+
"permissions": {
|
|
650
|
+
"description": "POSIX only; a reader on Windows skips the mode checks.",
|
|
651
|
+
"directory_mode": "0700",
|
|
652
|
+
"file_mode": "0600",
|
|
653
|
+
"writer": "The writer creates the directory 0700, writes a temporary file 0600 beside the target, syncs it and renames it over the target, so a crash never leaves a partial key. It refuses to replace or remove a symlink or anything that is not a regular file."
|
|
654
|
+
},
|
|
655
|
+
"key_sources": {
|
|
656
|
+
"description": "Where a client takes its key, first match wins. A library takes an explicit option first; the CLIs have none.",
|
|
657
|
+
"order": [
|
|
658
|
+
"option",
|
|
659
|
+
"env",
|
|
660
|
+
"file"
|
|
661
|
+
],
|
|
662
|
+
"env": "TONBO_API_KEY"
|
|
663
|
+
},
|
|
664
|
+
"reader": {
|
|
665
|
+
"missing": "No file is not logged in: ask the person to run `tonbo login`.",
|
|
666
|
+
"refuse_symlink": true,
|
|
667
|
+
"refuse_non_regular_file": true,
|
|
668
|
+
"refuse_mode_bits": "0077",
|
|
669
|
+
"refused": "A symlink, a non-regular file, or a file any group or other permission bit is set on is an error and is never read; do not fall back to another source.",
|
|
670
|
+
"unknown_version": "A `version` other than `current_version`, or content that does not match `schema`, is unusable: ask the person to run `tonbo login`. It is never migrated.",
|
|
671
|
+
"expired": "A login whose `expires_at` is at or before now is expired: ask the person to run `tonbo login`. `expires_at: null` never expires.",
|
|
672
|
+
"login_hint": "tonbo login"
|
|
673
|
+
},
|
|
674
|
+
"current_version": 1,
|
|
675
|
+
"schema": {
|
|
676
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
677
|
+
"$id": "https://tonbo.dev/contracts/cli/credentials-v1.json",
|
|
678
|
+
"title": "Tonbo CLI login",
|
|
679
|
+
"type": "object",
|
|
680
|
+
"required": [
|
|
681
|
+
"version",
|
|
682
|
+
"account_id",
|
|
683
|
+
"key_id",
|
|
684
|
+
"key",
|
|
685
|
+
"expires_at"
|
|
686
|
+
],
|
|
687
|
+
"additionalProperties": true,
|
|
688
|
+
"properties": {
|
|
689
|
+
"version": {
|
|
690
|
+
"const": 1
|
|
691
|
+
},
|
|
692
|
+
"account_id": {
|
|
693
|
+
"type": "string",
|
|
694
|
+
"minLength": 1,
|
|
695
|
+
"description": "The account the key belongs to (a UUID)."
|
|
696
|
+
},
|
|
697
|
+
"key_id": {
|
|
698
|
+
"type": "string",
|
|
699
|
+
"minLength": 1,
|
|
700
|
+
"description": "The key's id, its `client_id` at the exchange (a UUID)."
|
|
701
|
+
},
|
|
702
|
+
"key": {
|
|
703
|
+
"type": "string",
|
|
704
|
+
"pattern": "^tbo_",
|
|
705
|
+
"description": "The personal key's secret (contracts/iam/access-token-v1.json `personal_key`)."
|
|
706
|
+
},
|
|
707
|
+
"expires_at": {
|
|
708
|
+
"type": [
|
|
709
|
+
"string",
|
|
710
|
+
"null"
|
|
711
|
+
],
|
|
712
|
+
"format": "date-time",
|
|
713
|
+
"description": "When the key expires, ISO 8601; null for a key that never expires."
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
"examples": {
|
|
718
|
+
"usable": [
|
|
719
|
+
{
|
|
720
|
+
"version": 1,
|
|
721
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
722
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
723
|
+
"key": "tbo_secret",
|
|
724
|
+
"expires_at": "2027-04-24T00:00:00.000Z"
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
"version": 1,
|
|
728
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
729
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
730
|
+
"key": "tbo_secret",
|
|
731
|
+
"expires_at": null
|
|
732
|
+
}
|
|
733
|
+
],
|
|
734
|
+
"unusable": [
|
|
735
|
+
{
|
|
736
|
+
"access_token": "access",
|
|
737
|
+
"refresh_token": "refresh",
|
|
738
|
+
"token_type": "bearer"
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
"version": 2,
|
|
742
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
743
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
744
|
+
"key": "tbo_secret",
|
|
745
|
+
"expires_at": null
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
"version": 1,
|
|
749
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
750
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
751
|
+
"key": "not-a-personal-key",
|
|
752
|
+
"expires_at": null
|
|
753
|
+
},
|
|
754
|
+
{
|
|
755
|
+
"version": 1,
|
|
756
|
+
"account_id": "",
|
|
757
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
758
|
+
"key": "tbo_secret",
|
|
759
|
+
"expires_at": null
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
"version": 1,
|
|
763
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
764
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
765
|
+
"key": "tbo_secret",
|
|
766
|
+
"expires_at": "not a date"
|
|
767
|
+
},
|
|
768
|
+
{
|
|
769
|
+
"version": 1,
|
|
770
|
+
"account_id": "10000000-0000-4000-8000-000000000001",
|
|
771
|
+
"key_id": "20000000-0000-4000-8000-000000000002",
|
|
772
|
+
"key": "tbo_secret"
|
|
773
|
+
}
|
|
774
|
+
]
|
|
775
|
+
}
|
|
776
|
+
};
|
|
777
|
+
var machineIdleActions = [
|
|
778
|
+
"suspend",
|
|
779
|
+
"release"
|
|
780
|
+
];
|
|
781
|
+
var machinePortAccess = [
|
|
782
|
+
"private",
|
|
783
|
+
"public"
|
|
784
|
+
];
|
|
785
|
+
|
|
786
|
+
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
787
|
+
var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
|
|
788
|
+
var FILE_MODE = Number.parseInt(credentialsContract.permissions.file_mode, 8);
|
|
789
|
+
var REFUSED_MODE_BITS = Number.parseInt(credentialsContract.reader.refuse_mode_bits, 8);
|
|
790
|
+
function defaultCredentialsPath(env = process.env) {
|
|
791
|
+
const rule = credentialsContract.path;
|
|
792
|
+
const base = env[rule.base_env] || path.join(...rule.fallback_base.map((part) => part === "$HOME" ? os.homedir() : part));
|
|
793
|
+
return path.join(base, ...rule.relative);
|
|
794
|
+
}
|
|
795
|
+
function isExpiredLogin(login, now = Date.now()) {
|
|
796
|
+
return login.expires_at !== null && Date.parse(login.expires_at) <= now;
|
|
797
|
+
}
|
|
798
|
+
var UnusableLoginError = class extends Error {
|
|
799
|
+
};
|
|
800
|
+
var FileCredentialStore = class {
|
|
801
|
+
filename;
|
|
802
|
+
loginHint;
|
|
803
|
+
constructor(filename = defaultCredentialsPath(), loginHint = credentialsContract.reader.login_hint) {
|
|
804
|
+
this.filename = filename;
|
|
805
|
+
this.loginHint = loginHint;
|
|
806
|
+
}
|
|
807
|
+
async load() {
|
|
808
|
+
let parsed;
|
|
809
|
+
try {
|
|
810
|
+
await assertPrivateRegularFile(this.filename);
|
|
811
|
+
parsed = JSON.parse(await readFile(this.filename, "utf8"));
|
|
812
|
+
} catch (error) {
|
|
813
|
+
if (error.code === "ENOENT")
|
|
814
|
+
return null;
|
|
815
|
+
throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
816
|
+
}
|
|
817
|
+
if (!isStoredLogin(parsed))
|
|
818
|
+
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
819
|
+
return parsed;
|
|
820
|
+
}
|
|
821
|
+
async save(login) {
|
|
822
|
+
if (!isStoredLogin(login))
|
|
823
|
+
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
824
|
+
const directory = path.dirname(this.filename);
|
|
825
|
+
await mkdir(directory, { mode: DIRECTORY_MODE, recursive: true });
|
|
826
|
+
await preparePrivateDirectory(directory);
|
|
827
|
+
await assertExistingDestinationIsSafe(this.filename);
|
|
828
|
+
const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
|
|
829
|
+
let handle = null;
|
|
830
|
+
try {
|
|
831
|
+
handle = await open(temporary, "wx", FILE_MODE);
|
|
832
|
+
await handle.writeFile(`${JSON.stringify(login, null, 2)}
|
|
833
|
+
`, "utf8");
|
|
834
|
+
await handle.sync();
|
|
835
|
+
await handle.close();
|
|
836
|
+
handle = null;
|
|
837
|
+
if (process.platform !== "win32")
|
|
838
|
+
await chmod(temporary, FILE_MODE);
|
|
839
|
+
await rename(temporary, this.filename);
|
|
840
|
+
} catch (error) {
|
|
841
|
+
await handle?.close().catch(() => void 0);
|
|
842
|
+
await rm(temporary, { force: true }).catch(() => void 0);
|
|
843
|
+
throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
async remove() {
|
|
847
|
+
await assertExistingDestinationIsSafe(this.filename);
|
|
848
|
+
await rm(this.filename, { force: true });
|
|
849
|
+
}
|
|
850
|
+
};
|
|
851
|
+
var RULE_KEYWORDS = /* @__PURE__ */ new Set(["const", "type", "minLength", "pattern", "format", "description"]);
|
|
852
|
+
function matchesRule(value, rule) {
|
|
853
|
+
if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
|
|
854
|
+
return false;
|
|
855
|
+
if ("const" in rule)
|
|
856
|
+
return value === rule.const;
|
|
857
|
+
const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
|
|
858
|
+
if (value === null)
|
|
859
|
+
return types.includes("null");
|
|
860
|
+
if (typeof value !== "string" || !types.includes("string"))
|
|
861
|
+
return false;
|
|
862
|
+
if (rule.minLength !== void 0 && value.length < rule.minLength)
|
|
863
|
+
return false;
|
|
864
|
+
if (rule.pattern !== void 0 && !new RegExp(rule.pattern, "u").test(value))
|
|
865
|
+
return false;
|
|
866
|
+
if (rule.format === "date-time" && Number.isNaN(Date.parse(value)))
|
|
867
|
+
return false;
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
function isStoredLogin(value) {
|
|
871
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
872
|
+
return false;
|
|
873
|
+
const login = value;
|
|
874
|
+
const schema = credentialsContract.schema;
|
|
875
|
+
if (login.version !== credentialsContract.current_version)
|
|
876
|
+
return false;
|
|
877
|
+
if (!schema.required.every((name) => Object.hasOwn(login, name)))
|
|
878
|
+
return false;
|
|
879
|
+
const properties = schema.properties;
|
|
880
|
+
return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
|
|
881
|
+
}
|
|
882
|
+
async function preparePrivateDirectory(directory) {
|
|
883
|
+
const stat = await lstat(directory);
|
|
884
|
+
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
885
|
+
throw new Error("Tonbo config directory must be a real directory.");
|
|
886
|
+
if (process.platform !== "win32")
|
|
887
|
+
await chmod(directory, DIRECTORY_MODE);
|
|
888
|
+
}
|
|
889
|
+
async function assertExistingDestinationIsSafe(filename) {
|
|
890
|
+
try {
|
|
891
|
+
const stat = await lstat(filename);
|
|
892
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
893
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
894
|
+
} catch (error) {
|
|
895
|
+
if (error.code !== "ENOENT")
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async function assertPrivateRegularFile(filename) {
|
|
900
|
+
const stat = await lstat(filename);
|
|
901
|
+
if (stat.isSymbolicLink() || !stat.isFile())
|
|
902
|
+
throw new Error("Tonbo credential path must be a regular file.");
|
|
903
|
+
if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
|
|
904
|
+
throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// ../../packages/tonbo-cli-core/dist/src/auth.js
|
|
908
|
+
var exec = promisify(execFile);
|
|
909
|
+
var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
|
|
910
|
+
var LOOPBACK_HOST = "127.0.0.1";
|
|
911
|
+
function loginHostname(hostname = os2.hostname()) {
|
|
912
|
+
const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
|
|
913
|
+
return cleaned || "computer";
|
|
914
|
+
}
|
|
915
|
+
var AuthClient = class {
|
|
916
|
+
credentials;
|
|
917
|
+
fetcher;
|
|
918
|
+
loginOrigin;
|
|
919
|
+
browserOpener;
|
|
920
|
+
hostname;
|
|
921
|
+
loginHint;
|
|
922
|
+
constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
|
|
923
|
+
this.credentials = credentials;
|
|
924
|
+
this.fetcher = fetcher;
|
|
925
|
+
this.loginOrigin = loginOrigin2;
|
|
926
|
+
this.browserOpener = browserOpener;
|
|
927
|
+
this.hostname = hostname;
|
|
928
|
+
this.loginHint = loginHint;
|
|
929
|
+
}
|
|
930
|
+
/** The personal key requests are made with: TONBO_API_KEY, or the saved
|
|
931
|
+
* login while it has not expired. */
|
|
932
|
+
async apiKey() {
|
|
933
|
+
const injected = process.env.TONBO_API_KEY?.trim();
|
|
934
|
+
if (injected)
|
|
935
|
+
return injected;
|
|
936
|
+
const login = await this.credentials.load();
|
|
937
|
+
if (!login)
|
|
938
|
+
throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
|
|
939
|
+
if (isExpiredLogin(login))
|
|
940
|
+
throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
|
|
941
|
+
return login.key;
|
|
942
|
+
}
|
|
943
|
+
/**
|
|
944
|
+
* The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
|
|
945
|
+
* a free port, then the one-time code is redeemed for a personal key named
|
|
946
|
+
* "CLI on <hostname>", which replaces that host's earlier login key.
|
|
947
|
+
*/
|
|
948
|
+
async login(progress = () => {
|
|
949
|
+
}, account) {
|
|
950
|
+
const verifier = randomBytes(48).toString("base64url");
|
|
951
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
952
|
+
const state = randomBytes(24).toString("base64url");
|
|
953
|
+
const code = await this.listenForCode(state, (redirectUri) => {
|
|
954
|
+
const authorization = new URL("/cli/login", this.loginOrigin);
|
|
955
|
+
authorization.search = new URLSearchParams({
|
|
956
|
+
code_challenge: challenge,
|
|
957
|
+
code_challenge_method: "S256",
|
|
958
|
+
redirect_uri: redirectUri,
|
|
959
|
+
state,
|
|
960
|
+
hostname: this.hostname,
|
|
961
|
+
...account ? { account } : {}
|
|
962
|
+
}).toString();
|
|
963
|
+
return this.browserOpener(authorization.toString());
|
|
964
|
+
}, progress);
|
|
965
|
+
progress({ status: "started", step: "key-redemption" });
|
|
966
|
+
const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
|
|
967
|
+
method: "POST",
|
|
968
|
+
headers: { "content-type": "application/json" },
|
|
969
|
+
body: JSON.stringify({ code, code_verifier: verifier })
|
|
970
|
+
});
|
|
971
|
+
progress({ status: "completed", step: "key-redemption" });
|
|
972
|
+
const login = {
|
|
973
|
+
version: 1,
|
|
974
|
+
account_id: redeemed.account_id,
|
|
975
|
+
key_id: redeemed.api_key.id,
|
|
976
|
+
key: redeemed.key,
|
|
977
|
+
expires_at: redeemed.api_key.expires_at
|
|
978
|
+
};
|
|
979
|
+
progress({ status: "started", step: "credential-store" });
|
|
980
|
+
await this.credentials.save(login);
|
|
981
|
+
progress({ status: "completed", step: "credential-store" });
|
|
982
|
+
return login;
|
|
983
|
+
}
|
|
984
|
+
listenForCode(expectedState, ready, progress) {
|
|
985
|
+
return new Promise((resolve, reject) => {
|
|
986
|
+
let settled = false;
|
|
987
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
988
|
+
let authorizationStarted = false;
|
|
989
|
+
const startAuthorization = () => {
|
|
990
|
+
if (authorizationStarted)
|
|
991
|
+
return;
|
|
992
|
+
authorizationStarted = true;
|
|
993
|
+
progress({ status: "completed", step: "browser" });
|
|
994
|
+
progress({ status: "started", step: "browser-authorization" });
|
|
995
|
+
};
|
|
996
|
+
const finish = (result, completedResponseSocket) => {
|
|
997
|
+
if (settled)
|
|
998
|
+
return;
|
|
999
|
+
settled = true;
|
|
1000
|
+
clearTimeout(timeout);
|
|
1001
|
+
const complete = (closeError) => {
|
|
1002
|
+
if (closeError)
|
|
1003
|
+
reject(closeError);
|
|
1004
|
+
else if ("code" in result) {
|
|
1005
|
+
progress({ status: "completed", step: "callback-close" });
|
|
1006
|
+
resolve(result.code);
|
|
1007
|
+
} else
|
|
1008
|
+
reject(result.error);
|
|
1009
|
+
};
|
|
1010
|
+
if ("code" in result)
|
|
1011
|
+
progress({ status: "started", step: "callback-close" });
|
|
1012
|
+
if (!server.listening) {
|
|
1013
|
+
complete();
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
server.close(complete);
|
|
1017
|
+
for (const socket of sockets) {
|
|
1018
|
+
if (socket !== completedResponseSocket)
|
|
1019
|
+
socket.destroy();
|
|
1020
|
+
}
|
|
1021
|
+
server.closeIdleConnections();
|
|
1022
|
+
};
|
|
1023
|
+
const timeout = setTimeout(() => {
|
|
1024
|
+
finish({ error: new Error("Timed out waiting for browser login.") });
|
|
1025
|
+
}, 5 * 60 * 1e3);
|
|
1026
|
+
const server = createServer((request, response) => {
|
|
1027
|
+
const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
|
|
1028
|
+
if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
|
|
1029
|
+
respond(response, "invalid");
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
const denied = url.searchParams.get("error");
|
|
1033
|
+
if (denied) {
|
|
1034
|
+
const responseSocket2 = response.socket;
|
|
1035
|
+
respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const code = url.searchParams.get("code");
|
|
1039
|
+
if (!code) {
|
|
1040
|
+
respond(response, "invalid");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
startAuthorization();
|
|
1044
|
+
progress({ status: "completed", step: "browser-authorization" });
|
|
1045
|
+
const responseSocket = response.socket;
|
|
1046
|
+
respond(response, "complete", () => finish({ code }, responseSocket));
|
|
1047
|
+
});
|
|
1048
|
+
server.on("connection", (socket) => {
|
|
1049
|
+
sockets.add(socket);
|
|
1050
|
+
socket.once("close", () => sockets.delete(socket));
|
|
1051
|
+
});
|
|
1052
|
+
server.once("error", (error) => finish({ error }));
|
|
1053
|
+
progress({ status: "started", step: "callback-server" });
|
|
1054
|
+
server.listen(0, LOOPBACK_HOST, () => {
|
|
1055
|
+
const { port } = server.address();
|
|
1056
|
+
progress({ status: "completed", step: "callback-server" });
|
|
1057
|
+
progress({ status: "started", step: "browser" });
|
|
1058
|
+
void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
|
|
1059
|
+
error: error instanceof Error ? error : new Error("Could not open the login browser.")
|
|
1060
|
+
}));
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
719
1063
|
}
|
|
720
1064
|
};
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
"
|
|
730
|
-
|
|
731
|
-
"
|
|
732
|
-
];
|
|
1065
|
+
function respond(response, outcome, finished) {
|
|
1066
|
+
const page = callbackResponse(outcome);
|
|
1067
|
+
if (finished)
|
|
1068
|
+
response.once("finish", finished);
|
|
1069
|
+
response.writeHead(page.status, { ...page.headers, connection: "close" });
|
|
1070
|
+
response.end(page.body);
|
|
1071
|
+
}
|
|
1072
|
+
async function openBrowser(url) {
|
|
1073
|
+
if (process.platform === "darwin")
|
|
1074
|
+
return void await exec("open", [url]);
|
|
1075
|
+
if (process.platform === "win32")
|
|
1076
|
+
return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
|
|
1077
|
+
return void await exec("xdg-open", [url]);
|
|
1078
|
+
}
|
|
733
1079
|
|
|
734
1080
|
// ../../packages/tonbo-cli-core/dist/src/api.js
|
|
735
1081
|
var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
|
|
@@ -751,7 +1097,7 @@ var ManagementApi = class {
|
|
|
751
1097
|
page(operationId, key, { limit, cursor } = {}, query = {}) {
|
|
752
1098
|
return this.request(operationId, key, { query: { ...query, limit, cursor } });
|
|
753
1099
|
}
|
|
754
|
-
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey }) {
|
|
1100
|
+
async request(operationId, key, { params = {}, query = {}, body, idempotencyKey, ifMatch }) {
|
|
755
1101
|
const operation = managementOperations[operationId];
|
|
756
1102
|
const path2 = operation.path.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
757
1103
|
const value = params[name];
|
|
@@ -774,6 +1120,8 @@ var ManagementApi = class {
|
|
|
774
1120
|
headers["content-type"] = "application/json";
|
|
775
1121
|
if (idempotencyKey)
|
|
776
1122
|
headers["idempotency-key"] = idempotencyKey;
|
|
1123
|
+
if (ifMatch)
|
|
1124
|
+
headers["if-match"] = ifMatch;
|
|
777
1125
|
const queryString = search.toString();
|
|
778
1126
|
const url = `${this.apiOrigin}${managementApiBasePath}${path2}${queryString ? `?${queryString}` : ""}`;
|
|
779
1127
|
try {
|
|
@@ -871,6 +1219,44 @@ var ManagementApi = class {
|
|
|
871
1219
|
getUsage(key, period) {
|
|
872
1220
|
return this.call("getUsage", key, { query: { period } });
|
|
873
1221
|
}
|
|
1222
|
+
listMachines(key, page) {
|
|
1223
|
+
return this.page("listMachines", key, page);
|
|
1224
|
+
}
|
|
1225
|
+
/** A replay with the same idempotency key returns the Machine the first
|
|
1226
|
+
* request created. */
|
|
1227
|
+
createMachine(key, request, idempotencyKey) {
|
|
1228
|
+
return this.call("createMachine", key, {
|
|
1229
|
+
body: withoutUndefined(request),
|
|
1230
|
+
idempotencyKey
|
|
1231
|
+
}).catch(unconfirmed("Machine creation", idempotencyKey, "the same arguments"));
|
|
1232
|
+
}
|
|
1233
|
+
getMachine(key, name) {
|
|
1234
|
+
return this.call("getMachine", key, { params: { name } });
|
|
1235
|
+
}
|
|
1236
|
+
/** Written only at `revision` (machine-v2 requires If-Match, the Machine's
|
|
1237
|
+
* ETag); a stale one is refused with `revision_mismatch`. */
|
|
1238
|
+
updateMachine(key, name, revision, change) {
|
|
1239
|
+
return this.call("updateMachine", key, {
|
|
1240
|
+
params: { name },
|
|
1241
|
+
body: withoutUndefined(change),
|
|
1242
|
+
ifMatch: `"${revision}"`
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
sleepMachine(key, name) {
|
|
1246
|
+
return this.call("sleepMachine", key, { params: { name } });
|
|
1247
|
+
}
|
|
1248
|
+
/** The Machine being deleted, or null when the API answered 204: the
|
|
1249
|
+
* name's create never reached its region, and only the name was released. */
|
|
1250
|
+
async deleteMachine(key, name) {
|
|
1251
|
+
const answer = await this.request("deleteMachine", key, {
|
|
1252
|
+
params: { name }
|
|
1253
|
+
});
|
|
1254
|
+
return answer?.data ?? null;
|
|
1255
|
+
}
|
|
1256
|
+
/** The whole answer: the shapes and the default one. */
|
|
1257
|
+
listMachineShapes(key) {
|
|
1258
|
+
return this.request("listMachineShapes", key, {});
|
|
1259
|
+
}
|
|
874
1260
|
};
|
|
875
1261
|
function withoutUndefined(value) {
|
|
876
1262
|
return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0));
|
|
@@ -883,99 +1269,6 @@ function unconfirmed(what, idempotencyKey, same) {
|
|
|
883
1269
|
};
|
|
884
1270
|
}
|
|
885
1271
|
|
|
886
|
-
// ../../packages/tonbo-cli-core/dist/src/credentials.js
|
|
887
|
-
import { randomUUID } from "node:crypto";
|
|
888
|
-
import { chmod, lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises";
|
|
889
|
-
import os2 from "node:os";
|
|
890
|
-
import path from "node:path";
|
|
891
|
-
function defaultConfigDirectory() {
|
|
892
|
-
const base = process.env.XDG_CONFIG_HOME || path.join(os2.homedir(), ".config");
|
|
893
|
-
return path.join(base, "tonbo");
|
|
894
|
-
}
|
|
895
|
-
var UnusableLoginError = class extends Error {
|
|
896
|
-
};
|
|
897
|
-
var FileCredentialStore = class {
|
|
898
|
-
filename;
|
|
899
|
-
loginHint;
|
|
900
|
-
constructor(filename = path.join(defaultConfigDirectory(), "credentials.json"), loginHint = DEFAULT_LOGIN_HINT) {
|
|
901
|
-
this.filename = filename;
|
|
902
|
-
this.loginHint = loginHint;
|
|
903
|
-
}
|
|
904
|
-
async load() {
|
|
905
|
-
let parsed;
|
|
906
|
-
try {
|
|
907
|
-
await assertPrivateRegularFile(this.filename);
|
|
908
|
-
parsed = JSON.parse(await readFile(this.filename, "utf8"));
|
|
909
|
-
} catch (error) {
|
|
910
|
-
if (error.code === "ENOENT")
|
|
911
|
-
return null;
|
|
912
|
-
throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
913
|
-
}
|
|
914
|
-
if (!isStoredLogin(parsed))
|
|
915
|
-
throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
|
|
916
|
-
return parsed;
|
|
917
|
-
}
|
|
918
|
-
async save(login) {
|
|
919
|
-
if (!isStoredLogin(login))
|
|
920
|
-
throw new Error("Refusing to store a malformed Tonbo login.");
|
|
921
|
-
const directory = path.dirname(this.filename);
|
|
922
|
-
await mkdir(directory, { mode: 448, recursive: true });
|
|
923
|
-
await preparePrivateDirectory(directory);
|
|
924
|
-
await assertExistingDestinationIsSafe(this.filename);
|
|
925
|
-
const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
|
|
926
|
-
let handle = null;
|
|
927
|
-
try {
|
|
928
|
-
handle = await open(temporary, "wx", 384);
|
|
929
|
-
await handle.writeFile(`${JSON.stringify(login, null, 2)}
|
|
930
|
-
`, "utf8");
|
|
931
|
-
await handle.sync();
|
|
932
|
-
await handle.close();
|
|
933
|
-
handle = null;
|
|
934
|
-
if (process.platform !== "win32")
|
|
935
|
-
await chmod(temporary, 384);
|
|
936
|
-
await rename(temporary, this.filename);
|
|
937
|
-
} catch (error) {
|
|
938
|
-
await handle?.close().catch(() => void 0);
|
|
939
|
-
await rm(temporary, { force: true }).catch(() => void 0);
|
|
940
|
-
throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
|
|
941
|
-
}
|
|
942
|
-
}
|
|
943
|
-
async remove() {
|
|
944
|
-
await assertExistingDestinationIsSafe(this.filename);
|
|
945
|
-
await rm(this.filename, { force: true });
|
|
946
|
-
}
|
|
947
|
-
};
|
|
948
|
-
function isStoredLogin(value) {
|
|
949
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
950
|
-
return false;
|
|
951
|
-
const login = value;
|
|
952
|
-
return login.version === 1 && typeof login.account_id === "string" && login.account_id.length > 0 && typeof login.key_id === "string" && login.key_id.length > 0 && typeof login.key === "string" && login.key.startsWith("tbo_") && (login.expires_at === null || typeof login.expires_at === "string" && !Number.isNaN(Date.parse(login.expires_at)));
|
|
953
|
-
}
|
|
954
|
-
async function preparePrivateDirectory(directory) {
|
|
955
|
-
const stat = await lstat(directory);
|
|
956
|
-
if (stat.isSymbolicLink() || !stat.isDirectory())
|
|
957
|
-
throw new Error("Tonbo config directory must be a real directory.");
|
|
958
|
-
if (process.platform !== "win32")
|
|
959
|
-
await chmod(directory, 448);
|
|
960
|
-
}
|
|
961
|
-
async function assertExistingDestinationIsSafe(filename) {
|
|
962
|
-
try {
|
|
963
|
-
const stat = await lstat(filename);
|
|
964
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
965
|
-
throw new Error("Tonbo credential path must be a regular file.");
|
|
966
|
-
} catch (error) {
|
|
967
|
-
if (error.code !== "ENOENT")
|
|
968
|
-
throw error;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
async function assertPrivateRegularFile(filename) {
|
|
972
|
-
const stat = await lstat(filename);
|
|
973
|
-
if (stat.isSymbolicLink() || !stat.isFile())
|
|
974
|
-
throw new Error("Tonbo credential path must be a regular file.");
|
|
975
|
-
if (process.platform !== "win32" && (stat.mode & 63) !== 0)
|
|
976
|
-
throw new Error("Tonbo credential file must have mode 0600.");
|
|
977
|
-
}
|
|
978
|
-
|
|
979
1272
|
// ../../packages/tonbo-cli-core/dist/src/format.js
|
|
980
1273
|
function table(headings, rows) {
|
|
981
1274
|
const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
|
|
@@ -1402,12 +1695,20 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
|
|
|
1402
1695
|
"",
|
|
1403
1696
|
` ${created.key}`,
|
|
1404
1697
|
"",
|
|
1698
|
+
"For your application's environment (.env or secret store):",
|
|
1699
|
+
"",
|
|
1700
|
+
` TONBO_API_KEY=${created.key}`,
|
|
1701
|
+
"",
|
|
1405
1702
|
apiKeyFields(created)
|
|
1406
1703
|
].join("\n")
|
|
1407
1704
|
);
|
|
1408
1705
|
deps.notice(
|
|
1409
1706
|
"This is the only time the key's secret is shown: store it now. Anyone holding it acts as its member."
|
|
1410
1707
|
);
|
|
1708
|
+
if (!options.json)
|
|
1709
|
+
deps.notice(
|
|
1710
|
+
"Do not set TONBO_API_KEY in your own shell: it overrides your `tonbo login`, for this CLI too."
|
|
1711
|
+
);
|
|
1411
1712
|
}
|
|
1412
1713
|
async function apiKeyShowCommand(deps, keyId, options = {}) {
|
|
1413
1714
|
const apiKey = await deps.api.getApiKey(await deps.auth.apiKey(), keyId);
|
|
@@ -1582,6 +1883,13 @@ async function databaseCreateCommand(deps, name, options = {}) {
|
|
|
1582
1883
|
databaseFields(database)
|
|
1583
1884
|
].join("\n")
|
|
1584
1885
|
);
|
|
1886
|
+
if (!options.json) deps.notice(databaseNextStep(database.name));
|
|
1887
|
+
}
|
|
1888
|
+
function databaseNextStep(name) {
|
|
1889
|
+
return [
|
|
1890
|
+
"Next: npm i @tonbo/sqlite-ursula",
|
|
1891
|
+
` open(${JSON.stringify(name)}) from "@tonbo/sqlite-ursula/tonbo"`
|
|
1892
|
+
].join("\n");
|
|
1585
1893
|
}
|
|
1586
1894
|
async function databaseShowCommand(deps, name, options = {}) {
|
|
1587
1895
|
const database = await deps.api.getDatabase(await deps.auth.apiKey(), name);
|
|
@@ -1627,7 +1935,7 @@ async function databaseUsageCommand(deps, options = {}) {
|
|
|
1627
1935
|
const usage = await deps.api.listDatabaseUsage(
|
|
1628
1936
|
await deps.auth.apiKey(),
|
|
1629
1937
|
{ limit: options.limit, cursor: options.cursor },
|
|
1630
|
-
{ period: options.period, sort: options.sort, prefix: options.prefix }
|
|
1938
|
+
{ period: options.period, sort: options.sort, prefix: options.prefix, names: options.names }
|
|
1631
1939
|
);
|
|
1632
1940
|
if (options.json) deps.output(JSON.stringify(usage));
|
|
1633
1941
|
else
|
|
@@ -1685,6 +1993,283 @@ async function usageShowCommand(deps, options = {}) {
|
|
|
1685
1993
|
);
|
|
1686
1994
|
}
|
|
1687
1995
|
|
|
1996
|
+
// src/machine.ts
|
|
1997
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
1998
|
+
var MACHINE_ERROR_HINTS = {
|
|
1999
|
+
region_unavailable: "Machines are not open there yet, or that region cannot be reached from here; nothing was created or changed.",
|
|
2000
|
+
name_taken: "Choose another name; `tonbo machine list` shows the account's Machines.",
|
|
2001
|
+
hostname_taken: "The hostname <name>-<account> is held by another Machine or account; choose another name.",
|
|
2002
|
+
hostname_too_long: "<name>-<account> must fit one hostname label; choose a shorter name.",
|
|
2003
|
+
account_name_unclaimed: "Claim a Tonbo ID for the account on the console's Profile page, then run the command again.",
|
|
2004
|
+
image_platform_unsupported: "Machines run linux/arm64: publish the image for that platform (for example `docker buildx build --platform linux/arm64`).",
|
|
2005
|
+
shape_unavailable: "`tonbo machine shapes` lists the shapes a Machine may name now.",
|
|
2006
|
+
port_reserved: "Serve the application on another port.",
|
|
2007
|
+
revision_mismatch: "The Machine changed since it was read; `tonbo machine show` it and run the update again.",
|
|
2008
|
+
snapshot_would_be_discarded: "Pass --discard-snapshot to apply the change at the next boot, which then starts fresh.",
|
|
2009
|
+
database_limit_reached: "Each Machine counts one database: delete a Machine or a database (`tonbo database list`) first.",
|
|
2010
|
+
idempotency_conflict: "That idempotency key belongs to another request: leave out --idempotency-key to create a new Machine.",
|
|
2011
|
+
create_rate_limited: "Too many Machines were created in the last hour; try again later."
|
|
2012
|
+
};
|
|
2013
|
+
function explainMachineError(error) {
|
|
2014
|
+
if (!(error instanceof HttpError) || error.code === null) return error;
|
|
2015
|
+
const hint = MACHINE_ERROR_HINTS[error.code];
|
|
2016
|
+
if (!hint) return error;
|
|
2017
|
+
return new HttpError(`${error.message}
|
|
2018
|
+
${hint}`, error.status, error.body, error.requestId);
|
|
2019
|
+
}
|
|
2020
|
+
async function machineCommand(run) {
|
|
2021
|
+
try {
|
|
2022
|
+
return await run();
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
throw explainMachineError(error);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
function portText(port) {
|
|
2028
|
+
return `${port.port} ${port.access}${port.default ? " (default)" : ""}`;
|
|
2029
|
+
}
|
|
2030
|
+
function machinePortsText(machine) {
|
|
2031
|
+
return machine.ports.length ? machine.ports.map(portText).join(", ") : "none";
|
|
2032
|
+
}
|
|
2033
|
+
function idleActionText(machine) {
|
|
2034
|
+
const { action, reason } = machine.status.sleep;
|
|
2035
|
+
if (action === machine.idle_action) return action;
|
|
2036
|
+
return `${action} (${machine.idle_action} requested; ${reason.replaceAll("_", " ")})`;
|
|
2037
|
+
}
|
|
2038
|
+
function machineState(machine) {
|
|
2039
|
+
return machine.failure ? `${machine.state}: ${machine.failure.code}: ${machine.failure.message}` : machine.state;
|
|
2040
|
+
}
|
|
2041
|
+
function machineFields(machine) {
|
|
2042
|
+
return fields([
|
|
2043
|
+
["Name", machine.name],
|
|
2044
|
+
["ID", machine.id],
|
|
2045
|
+
["Region", machine.region],
|
|
2046
|
+
["State", machineState(machine)],
|
|
2047
|
+
["Power", machine.status.power],
|
|
2048
|
+
["Shape", machine.shape],
|
|
2049
|
+
["Image", `${machine.image.reference} (${machine.image.digest})`],
|
|
2050
|
+
["Command", machine.command ? machine.command.join(" ") : "the image's own"],
|
|
2051
|
+
["Ports", machinePortsText(machine)],
|
|
2052
|
+
["HTTPS", machine.hostnames.https],
|
|
2053
|
+
["SSH", machine.hostnames.ssh],
|
|
2054
|
+
["Env", Object.keys(machine.env).sort().join(", ") || "none"],
|
|
2055
|
+
["Sleep after", `${machine.sleep_after_seconds}s without a connection`],
|
|
2056
|
+
["Idle action", idleActionText(machine)],
|
|
2057
|
+
["Stop grace", `${machine.stop_grace_seconds}s`],
|
|
2058
|
+
["Database", machine.database.name],
|
|
2059
|
+
["Revision", String(machine.revision)],
|
|
2060
|
+
["Created", machine.created_at],
|
|
2061
|
+
["Updated", machine.updated_at]
|
|
2062
|
+
]);
|
|
2063
|
+
}
|
|
2064
|
+
function parsePorts(specs, current) {
|
|
2065
|
+
const accesses = machinePortAccess;
|
|
2066
|
+
return specs.map((spec) => {
|
|
2067
|
+
const match = /^([0-9]{1,5})(?::([a-z]+))?$/.exec(spec);
|
|
2068
|
+
const access = match?.[2] ?? "private";
|
|
2069
|
+
if (!match || !accesses.includes(access))
|
|
2070
|
+
throw new Error(
|
|
2071
|
+
`--port ${spec}: give <port> or <port>:<access>, access ${accesses.join(" or ")}.`
|
|
2072
|
+
);
|
|
2073
|
+
const port = Number(match[1]);
|
|
2074
|
+
const before = current.find((entry) => entry.port === port && entry.access === access);
|
|
2075
|
+
return {
|
|
2076
|
+
port,
|
|
2077
|
+
access,
|
|
2078
|
+
default: false,
|
|
2079
|
+
...before?.public_limits === void 0 ? {} : { public_limits: before.public_limits }
|
|
2080
|
+
};
|
|
2081
|
+
});
|
|
2082
|
+
}
|
|
2083
|
+
function withDefault(ports, defaultPort) {
|
|
2084
|
+
if (ports.length === 0) {
|
|
2085
|
+
if (defaultPort !== void 0) throw new Error("--default-port needs a port to choose.");
|
|
2086
|
+
return ports;
|
|
2087
|
+
}
|
|
2088
|
+
const chosen = defaultPort ?? ports[0].port;
|
|
2089
|
+
if (!ports.some((entry) => entry.port === chosen))
|
|
2090
|
+
throw new Error(`--default-port ${chosen} is not one of the Machine's ports.`);
|
|
2091
|
+
return ports.map((entry) => ({ ...entry, default: entry.port === chosen }));
|
|
2092
|
+
}
|
|
2093
|
+
function parseEnv(pairs) {
|
|
2094
|
+
const env = {};
|
|
2095
|
+
for (const pair of pairs) {
|
|
2096
|
+
const split = pair.indexOf("=");
|
|
2097
|
+
if (split < 1) throw new Error(`--env ${pair}: give KEY=VALUE.`);
|
|
2098
|
+
env[pair.slice(0, split)] = pair.slice(split + 1);
|
|
2099
|
+
}
|
|
2100
|
+
return env;
|
|
2101
|
+
}
|
|
2102
|
+
async function machineListCommand(deps, options = {}) {
|
|
2103
|
+
await machineCommand(async () => {
|
|
2104
|
+
const page = await deps.api.listMachines(await deps.auth.apiKey(), {
|
|
2105
|
+
limit: options.limit,
|
|
2106
|
+
cursor: options.cursor
|
|
2107
|
+
});
|
|
2108
|
+
printPage(
|
|
2109
|
+
deps,
|
|
2110
|
+
options.json,
|
|
2111
|
+
page,
|
|
2112
|
+
(machines) => machines.length ? table(
|
|
2113
|
+
["NAME", "REGION", "STATE", "POWER", "SHAPE", "IMAGE", "HTTPS"],
|
|
2114
|
+
machines.map((machine) => [
|
|
2115
|
+
machine.name,
|
|
2116
|
+
machine.region,
|
|
2117
|
+
machine.state,
|
|
2118
|
+
machine.status.power,
|
|
2119
|
+
machine.shape,
|
|
2120
|
+
machine.image.reference,
|
|
2121
|
+
machine.hostnames.https
|
|
2122
|
+
])
|
|
2123
|
+
) : "No Machines."
|
|
2124
|
+
);
|
|
2125
|
+
if (page.unavailable_regions.length)
|
|
2126
|
+
deps.notice(
|
|
2127
|
+
`Could not read the Machines in ${page.unavailable_regions.join(", ")}; they are missing from this list.`
|
|
2128
|
+
);
|
|
2129
|
+
});
|
|
2130
|
+
}
|
|
2131
|
+
async function machineCreateCommand(deps, name, command, options = {}) {
|
|
2132
|
+
await machineCommand(async () => {
|
|
2133
|
+
if (!options.image) throw new Error("Pass --image <reference>.");
|
|
2134
|
+
const env = parseEnv(options.env ?? []);
|
|
2135
|
+
const ports = withDefault(parsePorts(options.port ?? [], []), options.defaultPort);
|
|
2136
|
+
const key = await deps.auth.apiKey();
|
|
2137
|
+
const shape = options.shape ?? (await deps.api.listMachineShapes(key)).default;
|
|
2138
|
+
const machine = await deps.api.createMachine(
|
|
2139
|
+
key,
|
|
2140
|
+
{
|
|
2141
|
+
name,
|
|
2142
|
+
image: options.image,
|
|
2143
|
+
shape,
|
|
2144
|
+
region: options.region,
|
|
2145
|
+
command: command.length ? command : void 0,
|
|
2146
|
+
ports: ports.length ? ports : void 0,
|
|
2147
|
+
env: Object.keys(env).length ? env : void 0,
|
|
2148
|
+
sleep_after_seconds: options.sleepAfter,
|
|
2149
|
+
idle_action: options.idleAction,
|
|
2150
|
+
stop_grace_seconds: options.stopGrace
|
|
2151
|
+
},
|
|
2152
|
+
options.idempotencyKey ?? randomUUID3()
|
|
2153
|
+
);
|
|
2154
|
+
print(
|
|
2155
|
+
deps,
|
|
2156
|
+
options.json,
|
|
2157
|
+
machine,
|
|
2158
|
+
[
|
|
2159
|
+
machine.state === "active" ? `Created Machine ${machine.name}.` : `Machine ${machine.name} is being created; \`tonbo machine show ${machine.name}\` reports it active when it is ready.`,
|
|
2160
|
+
machineFields(machine)
|
|
2161
|
+
].join("\n")
|
|
2162
|
+
);
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
async function machineShowCommand(deps, name, options = {}) {
|
|
2166
|
+
await machineCommand(async () => {
|
|
2167
|
+
const machine = await deps.api.getMachine(await deps.auth.apiKey(), name);
|
|
2168
|
+
print(deps, options.json, machine, machineFields(machine));
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
2171
|
+
async function machineUpdateCommand(deps, name, command, options = {}) {
|
|
2172
|
+
await machineCommand(async () => {
|
|
2173
|
+
if (command.length && options.imageCommand)
|
|
2174
|
+
throw new Error("Give a command or --image-command, not both.");
|
|
2175
|
+
if (options.clearPorts && (options.port?.length || options.defaultPort !== void 0))
|
|
2176
|
+
throw new Error("Give --port or --clear-ports, not both.");
|
|
2177
|
+
const key = await deps.auth.apiKey();
|
|
2178
|
+
const current = await deps.api.getMachine(key, name);
|
|
2179
|
+
const change = {
|
|
2180
|
+
image: options.image,
|
|
2181
|
+
shape: options.shape,
|
|
2182
|
+
command: options.imageCommand ? null : command.length ? command : void 0,
|
|
2183
|
+
sleep_after_seconds: options.sleepAfter,
|
|
2184
|
+
idle_action: options.idleAction,
|
|
2185
|
+
stop_grace_seconds: options.stopGrace
|
|
2186
|
+
};
|
|
2187
|
+
if (options.clearPorts) change.ports = [];
|
|
2188
|
+
else if (options.port?.length) {
|
|
2189
|
+
const ports = parsePorts(options.port, current.ports);
|
|
2190
|
+
const kept = current.ports.find((entry) => entry.default)?.port;
|
|
2191
|
+
const defaultPort = options.defaultPort ?? (ports.some((entry) => entry.port === kept) ? kept : void 0);
|
|
2192
|
+
change.ports = withDefault(ports, defaultPort);
|
|
2193
|
+
} else if (options.defaultPort !== void 0)
|
|
2194
|
+
change.ports = withDefault(current.ports, options.defaultPort);
|
|
2195
|
+
if (options.env?.length || options.unsetEnv?.length) {
|
|
2196
|
+
const env = { ...current.env, ...parseEnv(options.env ?? []) };
|
|
2197
|
+
for (const variable of options.unsetEnv ?? []) delete env[variable];
|
|
2198
|
+
change.env = env;
|
|
2199
|
+
}
|
|
2200
|
+
if (options.discardSnapshot) change.discard_snapshot = true;
|
|
2201
|
+
if (Object.values(change).every((value) => value === void 0))
|
|
2202
|
+
throw new Error("Nothing to change: pass at least one option or a command.");
|
|
2203
|
+
const machine = await deps.api.updateMachine(
|
|
2204
|
+
key,
|
|
2205
|
+
name,
|
|
2206
|
+
options.revision ?? current.revision,
|
|
2207
|
+
change
|
|
2208
|
+
);
|
|
2209
|
+
print(
|
|
2210
|
+
deps,
|
|
2211
|
+
options.json,
|
|
2212
|
+
machine,
|
|
2213
|
+
[
|
|
2214
|
+
`Updated Machine ${machine.name} to revision ${machine.revision}. Ports and sleep-after apply now; image, command, shape and env at its next boot.`,
|
|
2215
|
+
machineFields(machine)
|
|
2216
|
+
].join("\n")
|
|
2217
|
+
);
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
async function machineSleepCommand(deps, name, options = {}) {
|
|
2221
|
+
await machineCommand(async () => {
|
|
2222
|
+
const machine = await deps.api.sleepMachine(await deps.auth.apiKey(), name);
|
|
2223
|
+
print(
|
|
2224
|
+
deps,
|
|
2225
|
+
options.json,
|
|
2226
|
+
machine,
|
|
2227
|
+
`Machine ${machine.name} is going to sleep (${machine.status.sleep.action}); its next connection wakes it.`
|
|
2228
|
+
);
|
|
2229
|
+
});
|
|
2230
|
+
}
|
|
2231
|
+
async function machineDeleteCommand(deps, name, options = {}) {
|
|
2232
|
+
await machineCommand(async () => {
|
|
2233
|
+
await confirm(
|
|
2234
|
+
deps,
|
|
2235
|
+
options.yes,
|
|
2236
|
+
`delete Machine ${name}`,
|
|
2237
|
+
() => Promise.resolve(
|
|
2238
|
+
`Delete Machine ${name} with its disk, database and logs? This cannot be undone. Type the Machine's name to confirm:`
|
|
2239
|
+
),
|
|
2240
|
+
name
|
|
2241
|
+
);
|
|
2242
|
+
const machine = await deps.api.deleteMachine(await deps.auth.apiKey(), name);
|
|
2243
|
+
print(
|
|
2244
|
+
deps,
|
|
2245
|
+
options.json,
|
|
2246
|
+
machine,
|
|
2247
|
+
machine ? `Machine ${machine.name} is being deleted; its name is free for a new Machine.` : `Released the name ${name}; no Machine had been created under it.`
|
|
2248
|
+
);
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
async function machineShapesCommand(deps, options = {}) {
|
|
2252
|
+
await machineCommand(async () => {
|
|
2253
|
+
const shapes = await deps.api.listMachineShapes(await deps.auth.apiKey());
|
|
2254
|
+
print(
|
|
2255
|
+
deps,
|
|
2256
|
+
options.json,
|
|
2257
|
+
shapes,
|
|
2258
|
+
table(
|
|
2259
|
+
["SHAPE", "VCPUS", "MEMORY", "SCRATCH", "OFFERED", "SUSPENDABLE"],
|
|
2260
|
+
shapes.data.map((shape) => [
|
|
2261
|
+
shape.shape === shapes.default ? `${shape.shape} (default)` : shape.shape,
|
|
2262
|
+
String(shape.vcpus),
|
|
2263
|
+
`${shape.memory_gib} GiB`,
|
|
2264
|
+
`${shape.scratch_gib} GiB`,
|
|
2265
|
+
shape.offered ? "yes" : "no",
|
|
2266
|
+
shape.suspendable ? "yes" : "no"
|
|
2267
|
+
])
|
|
2268
|
+
)
|
|
2269
|
+
);
|
|
2270
|
+
});
|
|
2271
|
+
}
|
|
2272
|
+
|
|
1688
2273
|
// src/prompt.ts
|
|
1689
2274
|
import select from "@inquirer/select";
|
|
1690
2275
|
import { createInterface } from "node:readline/promises";
|
|
@@ -1733,6 +2318,10 @@ function parseLimit(value) {
|
|
|
1733
2318
|
if (!/^[1-9][0-9]*$/.test(value)) throw new InvalidArgumentError("Not a positive integer.");
|
|
1734
2319
|
return Number(value);
|
|
1735
2320
|
}
|
|
2321
|
+
function parseWhole(value) {
|
|
2322
|
+
if (!/^[0-9]+$/.test(value)) throw new InvalidArgumentError("Not a whole number.");
|
|
2323
|
+
return Number(value);
|
|
2324
|
+
}
|
|
1736
2325
|
function operationOptions(command, operationId) {
|
|
1737
2326
|
const query = managementOperations[operationId].query;
|
|
1738
2327
|
if (query.includes("limit"))
|
|
@@ -1743,6 +2332,34 @@ function operationOptions(command, operationId) {
|
|
|
1743
2332
|
}
|
|
1744
2333
|
var repeated = (value, previous = []) => [...previous, value];
|
|
1745
2334
|
var roleOption = (description) => new Option("--role <role>", description).choices(accountRoles);
|
|
2335
|
+
function machineSpecOptions(command, update) {
|
|
2336
|
+
const later = update ? "; applies at the next boot" : "";
|
|
2337
|
+
return command.option("--shape <shape>", `the Machine's size (see tonbo machine shapes)${later}`).option(
|
|
2338
|
+
"--port <port[:access]>",
|
|
2339
|
+
`a port the Machine serves, private (the default) or public; repeat for several${update ? "; replaces every port" : ""}`,
|
|
2340
|
+
repeated
|
|
2341
|
+
).option(
|
|
2342
|
+
"--default-port <port>",
|
|
2343
|
+
`the port served at the Machine's hostname without a port prefix; the first --port by default${update ? ", or the current default while --port still lists it" : ""}`,
|
|
2344
|
+
parseWhole
|
|
2345
|
+
).option(
|
|
2346
|
+
"--env <KEY=VALUE>",
|
|
2347
|
+
`a non-secret environment variable; repeat for several${later}`,
|
|
2348
|
+
repeated
|
|
2349
|
+
).option(
|
|
2350
|
+
"--sleep-after <seconds>",
|
|
2351
|
+
"how long the Machine stays awake without a connection",
|
|
2352
|
+
parseWhole
|
|
2353
|
+
).addOption(
|
|
2354
|
+
new Option("--idle-action <action>", "what sleeping does to the Machine").choices(
|
|
2355
|
+
machineIdleActions
|
|
2356
|
+
)
|
|
2357
|
+
).option(
|
|
2358
|
+
"--stop-grace <seconds>",
|
|
2359
|
+
"the seconds between SIGTERM and SIGKILL on a planned stop",
|
|
2360
|
+
parseWhole
|
|
2361
|
+
);
|
|
2362
|
+
}
|
|
1746
2363
|
function createProgram(dependencies = createDependencies) {
|
|
1747
2364
|
const program = new Command().name("tonbo").description("Sign in to Tonbo and manage account resources.").version(packageVersion.version);
|
|
1748
2365
|
program.command("login").description("sign in through the browser and save a personal key for this computer").option("--account <name>", "the account to log in to; defaults to your personal account").action(async (options) => loginCommand(dependencies(), options));
|
|
@@ -1906,11 +2523,72 @@ function createProgram(dependencies = createDependencies) {
|
|
|
1906
2523
|
throw new InvalidArgumentError(error.message);
|
|
1907
2524
|
}
|
|
1908
2525
|
}
|
|
1909
|
-
).option("--prefix <prefix>", "only databases whose name starts with this"),
|
|
2526
|
+
).option("--prefix <prefix>", "only databases whose name starts with this").option("--names <names>", "only these databases, up to 100 names, comma-separated"),
|
|
1910
2527
|
"listDatabaseUsage"
|
|
1911
2528
|
).action(
|
|
1912
2529
|
async (options) => databaseUsageCommand(dependencies(), options)
|
|
1913
2530
|
);
|
|
2531
|
+
const machine = program.command("machine").description("the account's Machines");
|
|
2532
|
+
operationOptions(
|
|
2533
|
+
machine.command("list").description("list the Machines in every region"),
|
|
2534
|
+
"listMachines"
|
|
2535
|
+
).action(
|
|
2536
|
+
async (options) => machineListCommand(dependencies(), options)
|
|
2537
|
+
);
|
|
2538
|
+
operationOptions(
|
|
2539
|
+
machineSpecOptions(
|
|
2540
|
+
machine.command("create <name> [command...]").description(
|
|
2541
|
+
"create a Machine from a public linux/arm64 image; [command...] after -- replaces the image's CMD"
|
|
2542
|
+
).requiredOption("--image <reference>", "the public OCI image the Machine boots").option(
|
|
2543
|
+
"--region <region>",
|
|
2544
|
+
"the Machine's region, fixed for its life; the account's default region by default"
|
|
2545
|
+
),
|
|
2546
|
+
false
|
|
2547
|
+
).option(
|
|
2548
|
+
"--idempotency-key <key>",
|
|
2549
|
+
"reuse the key of an unconfirmed creation to get the Machine it created"
|
|
2550
|
+
),
|
|
2551
|
+
"createMachine"
|
|
2552
|
+
).action(
|
|
2553
|
+
async (name, command, options) => machineCreateCommand(dependencies(), name, command, options)
|
|
2554
|
+
);
|
|
2555
|
+
operationOptions(
|
|
2556
|
+
machine.command("show <name>").description("show a Machine, its hostnames and its state"),
|
|
2557
|
+
"getMachine"
|
|
2558
|
+
).action(
|
|
2559
|
+
async (name, options) => machineShowCommand(dependencies(), name, options)
|
|
2560
|
+
);
|
|
2561
|
+
operationOptions(
|
|
2562
|
+
machineSpecOptions(
|
|
2563
|
+
machine.command("update <name> [command...]").description(
|
|
2564
|
+
"change a Machine; [command...] after -- replaces its command at the next boot"
|
|
2565
|
+
).option("--image <reference>", "a new image; 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(
|
|
2566
|
+
"--revision <revision>",
|
|
2567
|
+
"write only if the Machine is still at this revision (tonbo machine show); the revision it reads by default",
|
|
2568
|
+
parseWhole
|
|
2569
|
+
).option("--discard-snapshot", "apply a change that discards the Machine's snapshot"),
|
|
2570
|
+
true
|
|
2571
|
+
),
|
|
2572
|
+
"updateMachine"
|
|
2573
|
+
).action(
|
|
2574
|
+
async (name, command, options) => machineUpdateCommand(dependencies(), name, command, options)
|
|
2575
|
+
);
|
|
2576
|
+
operationOptions(
|
|
2577
|
+
machine.command("sleep <name>").description("put a Machine to sleep now"),
|
|
2578
|
+
"sleepMachine"
|
|
2579
|
+
).action(
|
|
2580
|
+
async (name, options) => machineSleepCommand(dependencies(), name, options)
|
|
2581
|
+
);
|
|
2582
|
+
operationOptions(
|
|
2583
|
+
machine.command("delete <name>").description("delete a Machine with its disk, database and logs").option("--yes", "delete without asking; required when stdin is not a terminal"),
|
|
2584
|
+
"deleteMachine"
|
|
2585
|
+
).action(
|
|
2586
|
+
async (name, options) => machineDeleteCommand(dependencies(), name, options)
|
|
2587
|
+
);
|
|
2588
|
+
operationOptions(
|
|
2589
|
+
machine.command("shapes").description("list the shapes a Machine may have"),
|
|
2590
|
+
"listMachineShapes"
|
|
2591
|
+
).action(async (options) => machineShapesCommand(dependencies(), options));
|
|
1914
2592
|
operationOptions(
|
|
1915
2593
|
program.command("usage").description("the account's rated usage").command("show").description("show one month's rated usage").option(
|
|
1916
2594
|
"--period <month>",
|