@tonbo/cli 0.6.0 → 0.6.1

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 +17 -1
  2. package/dist/bin/tonbo.js +470 -278
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -96,7 +96,7 @@ tonbo api-key create ci-reader --member pi-live --read-only --bucket events --ex
96
96
  tonbo api-key revoke 70000000-0000-4000-8000-000000000007
97
97
  ```
98
98
 
99
- `api-key list` shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret. `api-key create` creates a personal key for you, or with `--member` a key of an Agent (its subject, id or name; owners and admins only). `--read-only`, and either `--bucket` or `--database` and `--database-prefix` (repeat each for several), narrow the key for its whole life; `--expires-in` is one of `15_days`, `1_month`, `6_months`, `1_year` or `never` (the default). The secret is printed once, in that answer only, with a warning on stderr: store it then, because no command can show it again. `api-key show` prints one key, and `api-key revoke` revokes it and every token minted from it.
99
+ `api-key list` shows your keys, or every key of the account for an owner or admin, with the last four characters of each secret. `api-key create` creates a personal key for you, or with `--member` a key of an Agent (its subject, id or name; owners and admins only). `--read-only`, and either `--bucket` or `--database` and `--database-prefix` (repeat each for several), narrow the key for its whole life; `--expires-in` is one of `15_days`, `1_month`, `6_months`, `1_year` or `never` (the default). The secret is printed once, in that answer only, with a warning on stderr: store it then, because no command can show it again. Without `--json`, the answer also carries a ready-to-paste `TONBO_API_KEY=<secret>` line on stdout, beside the secret, for your application's environment (a `.env` file or a secret store), and stderr notes that `TONBO_API_KEY` in your own shell overrides your `tonbo login`, for this CLI too. stderr never carries the secret, and `--json` prints only the answer. `api-key show` prints one key, and `api-key revoke` revokes it and every token minted from it.
100
100
 
101
101
  ## Buckets
102
102
 
@@ -108,6 +108,22 @@ tonbo bucket delete events
108
108
 
109
109
  `bucket list` and `bucket show` print each bucket's region, state, read access and URL; a stream lives at `{url}/{stream}`. `bucket create` creates a bucket in `--region`, or in the account's default region. `bucket update` publishes a bucket for anonymous reads with `--public` or takes it private with `--private`. `bucket delete` deletes a bucket and every stream in it; the bucket's name is never used again.
110
110
 
111
+ ## Databases
112
+
113
+ ```console
114
+ tonbo database create notes
115
+ tonbo database show notes
116
+ ```
117
+
118
+ `database create` creates a database in `--region`, or in the default region, and prints its fields with its URL. Without `--json` it then writes the next step to stderr, opening the database by name from code:
119
+
120
+ ```text
121
+ Next: npm i @tonbo/sqlite-ursula
122
+ open("notes") from "@tonbo/sqlite-ursula/tonbo"
123
+ ```
124
+
125
+ `open("notes")` resolves the database through `GET /v1/databases/notes`. It takes its key from, in order, its `apiKey` option, then `TONBO_API_KEY`, then the `tonbo login` credentials file (`key_sources` in `contracts/cli/credentials-v1.json`). A key narrowed to databases reads the databases it reaches there, so an application can be given a narrowed key.
126
+
111
127
  ## Usage
112
128
 
113
129
  `usage show` prints the account's rated usage of one calendar month in UTC, the current one unless `--period YYYY-MM` names another: one line per product, region, dimension and model with its quantity and amount, and the total, in US dollars.
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 = {
@@ -718,18 +551,455 @@ var managementOperations = {
718
551
  "cli": "tonbo database usage"
719
552
  }
720
553
  };
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
- ];
554
+ var accountRoles = [
555
+ "owner",
556
+ "admin",
557
+ "member"
558
+ ];
559
+ var apiKeyExpirations = [
560
+ "15_days",
561
+ "1_month",
562
+ "6_months",
563
+ "1_year",
564
+ "never"
565
+ ];
566
+ var credentialsContract = {
567
+ "version": 1,
568
+ "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`.",
569
+ "path": {
570
+ "description": "`${XDG_CONFIG_HOME}/tonbo/credentials.json` when XDG_CONFIG_HOME is set and not empty, else `${HOME}/.config/tonbo/credentials.json`.",
571
+ "base_env": "XDG_CONFIG_HOME",
572
+ "fallback_base": [
573
+ "$HOME",
574
+ ".config"
575
+ ],
576
+ "relative": [
577
+ "tonbo",
578
+ "credentials.json"
579
+ ]
580
+ },
581
+ "permissions": {
582
+ "description": "POSIX only; a reader on Windows skips the mode checks.",
583
+ "directory_mode": "0700",
584
+ "file_mode": "0600",
585
+ "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."
586
+ },
587
+ "key_sources": {
588
+ "description": "Where a client takes its key, first match wins. A library takes an explicit option first; the CLIs have none.",
589
+ "order": [
590
+ "option",
591
+ "env",
592
+ "file"
593
+ ],
594
+ "env": "TONBO_API_KEY"
595
+ },
596
+ "reader": {
597
+ "missing": "No file is not logged in: ask the person to run `tonbo login`.",
598
+ "refuse_symlink": true,
599
+ "refuse_non_regular_file": true,
600
+ "refuse_mode_bits": "0077",
601
+ "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.",
602
+ "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.",
603
+ "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.",
604
+ "login_hint": "tonbo login"
605
+ },
606
+ "current_version": 1,
607
+ "schema": {
608
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
609
+ "$id": "https://tonbo.dev/contracts/cli/credentials-v1.json",
610
+ "title": "Tonbo CLI login",
611
+ "type": "object",
612
+ "required": [
613
+ "version",
614
+ "account_id",
615
+ "key_id",
616
+ "key",
617
+ "expires_at"
618
+ ],
619
+ "additionalProperties": true,
620
+ "properties": {
621
+ "version": {
622
+ "const": 1
623
+ },
624
+ "account_id": {
625
+ "type": "string",
626
+ "minLength": 1,
627
+ "description": "The account the key belongs to (a UUID)."
628
+ },
629
+ "key_id": {
630
+ "type": "string",
631
+ "minLength": 1,
632
+ "description": "The key's id, its `client_id` at the exchange (a UUID)."
633
+ },
634
+ "key": {
635
+ "type": "string",
636
+ "pattern": "^tbo_",
637
+ "description": "The personal key's secret (contracts/iam/access-token-v1.json `personal_key`)."
638
+ },
639
+ "expires_at": {
640
+ "type": [
641
+ "string",
642
+ "null"
643
+ ],
644
+ "format": "date-time",
645
+ "description": "When the key expires, ISO 8601; null for a key that never expires."
646
+ }
647
+ }
648
+ },
649
+ "examples": {
650
+ "usable": [
651
+ {
652
+ "version": 1,
653
+ "account_id": "10000000-0000-4000-8000-000000000001",
654
+ "key_id": "20000000-0000-4000-8000-000000000002",
655
+ "key": "tbo_secret",
656
+ "expires_at": "2027-04-24T00:00:00.000Z"
657
+ },
658
+ {
659
+ "version": 1,
660
+ "account_id": "10000000-0000-4000-8000-000000000001",
661
+ "key_id": "20000000-0000-4000-8000-000000000002",
662
+ "key": "tbo_secret",
663
+ "expires_at": null
664
+ }
665
+ ],
666
+ "unusable": [
667
+ {
668
+ "access_token": "access",
669
+ "refresh_token": "refresh",
670
+ "token_type": "bearer"
671
+ },
672
+ {
673
+ "version": 2,
674
+ "account_id": "10000000-0000-4000-8000-000000000001",
675
+ "key_id": "20000000-0000-4000-8000-000000000002",
676
+ "key": "tbo_secret",
677
+ "expires_at": null
678
+ },
679
+ {
680
+ "version": 1,
681
+ "account_id": "10000000-0000-4000-8000-000000000001",
682
+ "key_id": "20000000-0000-4000-8000-000000000002",
683
+ "key": "not-a-personal-key",
684
+ "expires_at": null
685
+ },
686
+ {
687
+ "version": 1,
688
+ "account_id": "",
689
+ "key_id": "20000000-0000-4000-8000-000000000002",
690
+ "key": "tbo_secret",
691
+ "expires_at": null
692
+ },
693
+ {
694
+ "version": 1,
695
+ "account_id": "10000000-0000-4000-8000-000000000001",
696
+ "key_id": "20000000-0000-4000-8000-000000000002",
697
+ "key": "tbo_secret",
698
+ "expires_at": "not a date"
699
+ },
700
+ {
701
+ "version": 1,
702
+ "account_id": "10000000-0000-4000-8000-000000000001",
703
+ "key_id": "20000000-0000-4000-8000-000000000002",
704
+ "key": "tbo_secret"
705
+ }
706
+ ]
707
+ }
708
+ };
709
+
710
+ // ../../packages/tonbo-cli-core/dist/src/credentials.js
711
+ var DIRECTORY_MODE = Number.parseInt(credentialsContract.permissions.directory_mode, 8);
712
+ var FILE_MODE = Number.parseInt(credentialsContract.permissions.file_mode, 8);
713
+ var REFUSED_MODE_BITS = Number.parseInt(credentialsContract.reader.refuse_mode_bits, 8);
714
+ function defaultCredentialsPath(env = process.env) {
715
+ const rule = credentialsContract.path;
716
+ const base = env[rule.base_env] || path.join(...rule.fallback_base.map((part) => part === "$HOME" ? os.homedir() : part));
717
+ return path.join(base, ...rule.relative);
718
+ }
719
+ function isExpiredLogin(login, now = Date.now()) {
720
+ return login.expires_at !== null && Date.parse(login.expires_at) <= now;
721
+ }
722
+ var UnusableLoginError = class extends Error {
723
+ };
724
+ var FileCredentialStore = class {
725
+ filename;
726
+ loginHint;
727
+ constructor(filename = defaultCredentialsPath(), loginHint = credentialsContract.reader.login_hint) {
728
+ this.filename = filename;
729
+ this.loginHint = loginHint;
730
+ }
731
+ async load() {
732
+ let parsed;
733
+ try {
734
+ await assertPrivateRegularFile(this.filename);
735
+ parsed = JSON.parse(await readFile(this.filename, "utf8"));
736
+ } catch (error) {
737
+ if (error.code === "ENOENT")
738
+ return null;
739
+ throw new Error(`Could not read Tonbo credentials at ${this.filename}.`, { cause: error });
740
+ }
741
+ if (!isStoredLogin(parsed))
742
+ throw new UnusableLoginError(`The saved login at ${this.filename} is not usable by this CLI. Run \`${this.loginHint}\`.`);
743
+ return parsed;
744
+ }
745
+ async save(login) {
746
+ if (!isStoredLogin(login))
747
+ throw new Error("Refusing to store a malformed Tonbo login.");
748
+ const directory = path.dirname(this.filename);
749
+ await mkdir(directory, { mode: DIRECTORY_MODE, recursive: true });
750
+ await preparePrivateDirectory(directory);
751
+ await assertExistingDestinationIsSafe(this.filename);
752
+ const temporary = path.join(directory, `.${path.basename(this.filename)}.${process.pid}.${randomUUID()}.tmp`);
753
+ let handle = null;
754
+ try {
755
+ handle = await open(temporary, "wx", FILE_MODE);
756
+ await handle.writeFile(`${JSON.stringify(login, null, 2)}
757
+ `, "utf8");
758
+ await handle.sync();
759
+ await handle.close();
760
+ handle = null;
761
+ if (process.platform !== "win32")
762
+ await chmod(temporary, FILE_MODE);
763
+ await rename(temporary, this.filename);
764
+ } catch (error) {
765
+ await handle?.close().catch(() => void 0);
766
+ await rm(temporary, { force: true }).catch(() => void 0);
767
+ throw new Error(`Could not store Tonbo credentials at ${this.filename}.`, { cause: error });
768
+ }
769
+ }
770
+ async remove() {
771
+ await assertExistingDestinationIsSafe(this.filename);
772
+ await rm(this.filename, { force: true });
773
+ }
774
+ };
775
+ var RULE_KEYWORDS = /* @__PURE__ */ new Set(["const", "type", "minLength", "pattern", "format", "description"]);
776
+ function matchesRule(value, rule) {
777
+ if (Object.keys(rule).some((keyword) => !RULE_KEYWORDS.has(keyword)))
778
+ return false;
779
+ if ("const" in rule)
780
+ return value === rule.const;
781
+ const types = typeof rule.type === "string" ? [rule.type] : rule.type ?? [];
782
+ if (value === null)
783
+ return types.includes("null");
784
+ if (typeof value !== "string" || !types.includes("string"))
785
+ return false;
786
+ if (rule.minLength !== void 0 && value.length < rule.minLength)
787
+ return false;
788
+ if (rule.pattern !== void 0 && !new RegExp(rule.pattern, "u").test(value))
789
+ return false;
790
+ if (rule.format === "date-time" && Number.isNaN(Date.parse(value)))
791
+ return false;
792
+ return true;
793
+ }
794
+ function isStoredLogin(value) {
795
+ if (!value || typeof value !== "object" || Array.isArray(value))
796
+ return false;
797
+ const login = value;
798
+ const schema = credentialsContract.schema;
799
+ if (login.version !== credentialsContract.current_version)
800
+ return false;
801
+ if (!schema.required.every((name) => Object.hasOwn(login, name)))
802
+ return false;
803
+ const properties = schema.properties;
804
+ return Object.entries(properties).every(([name, rule]) => !Object.hasOwn(login, name) || matchesRule(login[name], rule));
805
+ }
806
+ async function preparePrivateDirectory(directory) {
807
+ const stat = await lstat(directory);
808
+ if (stat.isSymbolicLink() || !stat.isDirectory())
809
+ throw new Error("Tonbo config directory must be a real directory.");
810
+ if (process.platform !== "win32")
811
+ await chmod(directory, DIRECTORY_MODE);
812
+ }
813
+ async function assertExistingDestinationIsSafe(filename) {
814
+ try {
815
+ const stat = await lstat(filename);
816
+ if (stat.isSymbolicLink() || !stat.isFile())
817
+ throw new Error("Tonbo credential path must be a regular file.");
818
+ } catch (error) {
819
+ if (error.code !== "ENOENT")
820
+ throw error;
821
+ }
822
+ }
823
+ async function assertPrivateRegularFile(filename) {
824
+ const stat = await lstat(filename);
825
+ if (stat.isSymbolicLink() || !stat.isFile())
826
+ throw new Error("Tonbo credential path must be a regular file.");
827
+ if (process.platform !== "win32" && (stat.mode & REFUSED_MODE_BITS) !== 0)
828
+ throw new Error(`Tonbo credential file must have mode ${credentialsContract.permissions.file_mode}.`);
829
+ }
830
+
831
+ // ../../packages/tonbo-cli-core/dist/src/auth.js
832
+ var exec = promisify(execFile);
833
+ var DEFAULT_LOGIN_HINT = credentialsContract.reader.login_hint;
834
+ var LOOPBACK_HOST = "127.0.0.1";
835
+ function loginHostname(hostname = os2.hostname()) {
836
+ const cleaned = hostname.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^[^A-Za-z0-9]+/, "").slice(0, 64);
837
+ return cleaned || "computer";
838
+ }
839
+ var AuthClient = class {
840
+ credentials;
841
+ fetcher;
842
+ loginOrigin;
843
+ browserOpener;
844
+ hostname;
845
+ loginHint;
846
+ constructor(credentials, fetcher, loginOrigin2 = "https://tonbo.dev", browserOpener = openBrowser, hostname = loginHostname(), loginHint = DEFAULT_LOGIN_HINT) {
847
+ this.credentials = credentials;
848
+ this.fetcher = fetcher;
849
+ this.loginOrigin = loginOrigin2;
850
+ this.browserOpener = browserOpener;
851
+ this.hostname = hostname;
852
+ this.loginHint = loginHint;
853
+ }
854
+ /** The personal key requests are made with: TONBO_API_KEY, or the saved
855
+ * login while it has not expired. */
856
+ async apiKey() {
857
+ const injected = process.env.TONBO_API_KEY?.trim();
858
+ if (injected)
859
+ return injected;
860
+ const login = await this.credentials.load();
861
+ if (!login)
862
+ throw new Error(`Not logged in. Run \`${this.loginHint}\`.`);
863
+ if (isExpiredLogin(login))
864
+ throw new Error(`Your Tonbo login has expired. Run \`${this.loginHint}\`.`);
865
+ return login.key;
866
+ }
867
+ /**
868
+ * The browser handoff of /cli/login: PKCE S256 with a loopback redirect on
869
+ * a free port, then the one-time code is redeemed for a personal key named
870
+ * "CLI on <hostname>", which replaces that host's earlier login key.
871
+ */
872
+ async login(progress = () => {
873
+ }, account) {
874
+ const verifier = randomBytes(48).toString("base64url");
875
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
876
+ const state = randomBytes(24).toString("base64url");
877
+ const code = await this.listenForCode(state, (redirectUri) => {
878
+ const authorization = new URL("/cli/login", this.loginOrigin);
879
+ authorization.search = new URLSearchParams({
880
+ code_challenge: challenge,
881
+ code_challenge_method: "S256",
882
+ redirect_uri: redirectUri,
883
+ state,
884
+ hostname: this.hostname,
885
+ ...account ? { account } : {}
886
+ }).toString();
887
+ return this.browserOpener(authorization.toString());
888
+ }, progress);
889
+ progress({ status: "started", step: "key-redemption" });
890
+ const redeemed = await requestJson(this.fetcher, new URL("/api/cli/login/token", this.loginOrigin).toString(), {
891
+ method: "POST",
892
+ headers: { "content-type": "application/json" },
893
+ body: JSON.stringify({ code, code_verifier: verifier })
894
+ });
895
+ progress({ status: "completed", step: "key-redemption" });
896
+ const login = {
897
+ version: 1,
898
+ account_id: redeemed.account_id,
899
+ key_id: redeemed.api_key.id,
900
+ key: redeemed.key,
901
+ expires_at: redeemed.api_key.expires_at
902
+ };
903
+ progress({ status: "started", step: "credential-store" });
904
+ await this.credentials.save(login);
905
+ progress({ status: "completed", step: "credential-store" });
906
+ return login;
907
+ }
908
+ listenForCode(expectedState, ready, progress) {
909
+ return new Promise((resolve, reject) => {
910
+ let settled = false;
911
+ const sockets = /* @__PURE__ */ new Set();
912
+ let authorizationStarted = false;
913
+ const startAuthorization = () => {
914
+ if (authorizationStarted)
915
+ return;
916
+ authorizationStarted = true;
917
+ progress({ status: "completed", step: "browser" });
918
+ progress({ status: "started", step: "browser-authorization" });
919
+ };
920
+ const finish = (result, completedResponseSocket) => {
921
+ if (settled)
922
+ return;
923
+ settled = true;
924
+ clearTimeout(timeout);
925
+ const complete = (closeError) => {
926
+ if (closeError)
927
+ reject(closeError);
928
+ else if ("code" in result) {
929
+ progress({ status: "completed", step: "callback-close" });
930
+ resolve(result.code);
931
+ } else
932
+ reject(result.error);
933
+ };
934
+ if ("code" in result)
935
+ progress({ status: "started", step: "callback-close" });
936
+ if (!server.listening) {
937
+ complete();
938
+ return;
939
+ }
940
+ server.close(complete);
941
+ for (const socket of sockets) {
942
+ if (socket !== completedResponseSocket)
943
+ socket.destroy();
944
+ }
945
+ server.closeIdleConnections();
946
+ };
947
+ const timeout = setTimeout(() => {
948
+ finish({ error: new Error("Timed out waiting for browser login.") });
949
+ }, 5 * 60 * 1e3);
950
+ const server = createServer((request, response) => {
951
+ const url = new URL(request.url ?? "/", `http://${LOOPBACK_HOST}`);
952
+ if (url.pathname !== "/callback" || url.searchParams.get("state") !== expectedState) {
953
+ respond(response, "invalid");
954
+ return;
955
+ }
956
+ const denied = url.searchParams.get("error");
957
+ if (denied) {
958
+ const responseSocket2 = response.socket;
959
+ respond(response, "denied", () => finish({ error: new Error(`The login was not approved (${denied}).`) }, responseSocket2));
960
+ return;
961
+ }
962
+ const code = url.searchParams.get("code");
963
+ if (!code) {
964
+ respond(response, "invalid");
965
+ return;
966
+ }
967
+ startAuthorization();
968
+ progress({ status: "completed", step: "browser-authorization" });
969
+ const responseSocket = response.socket;
970
+ respond(response, "complete", () => finish({ code }, responseSocket));
971
+ });
972
+ server.on("connection", (socket) => {
973
+ sockets.add(socket);
974
+ socket.once("close", () => sockets.delete(socket));
975
+ });
976
+ server.once("error", (error) => finish({ error }));
977
+ progress({ status: "started", step: "callback-server" });
978
+ server.listen(0, LOOPBACK_HOST, () => {
979
+ const { port } = server.address();
980
+ progress({ status: "completed", step: "callback-server" });
981
+ progress({ status: "started", step: "browser" });
982
+ void ready(`http://${LOOPBACK_HOST}:${port}/callback`).then(startAuthorization).catch((error) => finish({
983
+ error: error instanceof Error ? error : new Error("Could not open the login browser.")
984
+ }));
985
+ });
986
+ });
987
+ }
988
+ };
989
+ function respond(response, outcome, finished) {
990
+ const page = callbackResponse(outcome);
991
+ if (finished)
992
+ response.once("finish", finished);
993
+ response.writeHead(page.status, { ...page.headers, connection: "close" });
994
+ response.end(page.body);
995
+ }
996
+ async function openBrowser(url) {
997
+ if (process.platform === "darwin")
998
+ return void await exec("open", [url]);
999
+ if (process.platform === "win32")
1000
+ return void await exec("rundll32", ["url.dll,FileProtocolHandler", url]);
1001
+ return void await exec("xdg-open", [url]);
1002
+ }
733
1003
 
734
1004
  // ../../packages/tonbo-cli-core/dist/src/api.js
735
1005
  var DEFAULT_API_ORIGIN = "https://api.tonbo.dev";
@@ -883,99 +1153,6 @@ function unconfirmed(what, idempotencyKey, same) {
883
1153
  };
884
1154
  }
885
1155
 
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
1156
  // ../../packages/tonbo-cli-core/dist/src/format.js
980
1157
  function table(headings, rows) {
981
1158
  const widths = headings.map((heading, column) => Math.max(heading.length, ...rows.map((row) => (row[column] ?? "").length)));
@@ -1402,12 +1579,20 @@ async function apiKeyCreateCommand(deps, name, options = {}) {
1402
1579
  "",
1403
1580
  ` ${created.key}`,
1404
1581
  "",
1582
+ "For your application's environment (.env or secret store):",
1583
+ "",
1584
+ ` TONBO_API_KEY=${created.key}`,
1585
+ "",
1405
1586
  apiKeyFields(created)
1406
1587
  ].join("\n")
1407
1588
  );
1408
1589
  deps.notice(
1409
1590
  "This is the only time the key's secret is shown: store it now. Anyone holding it acts as its member."
1410
1591
  );
1592
+ if (!options.json)
1593
+ deps.notice(
1594
+ "Do not set TONBO_API_KEY in your own shell: it overrides your `tonbo login`, for this CLI too."
1595
+ );
1411
1596
  }
1412
1597
  async function apiKeyShowCommand(deps, keyId, options = {}) {
1413
1598
  const apiKey = await deps.api.getApiKey(await deps.auth.apiKey(), keyId);
@@ -1582,6 +1767,13 @@ async function databaseCreateCommand(deps, name, options = {}) {
1582
1767
  databaseFields(database)
1583
1768
  ].join("\n")
1584
1769
  );
1770
+ if (!options.json) deps.notice(databaseNextStep(database.name));
1771
+ }
1772
+ function databaseNextStep(name) {
1773
+ return [
1774
+ "Next: npm i @tonbo/sqlite-ursula",
1775
+ ` open(${JSON.stringify(name)}) from "@tonbo/sqlite-ursula/tonbo"`
1776
+ ].join("\n");
1585
1777
  }
1586
1778
  async function databaseShowCommand(deps, name, options = {}) {
1587
1779
  const database = await deps.api.getDatabase(await deps.auth.apiKey(), name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "The Tonbo CLI: sign in and manage account resources from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {