@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.
Files changed (3) hide show
  1. package/README.md +47 -5
  2. package/dist/bin/tonbo.js +959 -281
  3. 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 os from "node:os";
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/auth.js
181
- var exec = promisify(execFile);
182
- var DEFAULT_LOGIN_HINT = "tonbo login";
183
- var LOOPBACK_HOST = "127.0.0.1";
184
- function loginHostname(hostname = os.hostname()) {
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
- var accountRoles = [
722
- "owner",
723
- "admin",
724
- "member"
725
- ];
726
- var apiKeyExpirations = [
727
- "15_days",
728
- "1_month",
729
- "6_months",
730
- "1_year",
731
- "never"
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>",