@tonbo/cli 0.0.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.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # Tonbo CLI
2
+
3
+ Install the public package with Node.js 22 or newer:
4
+
5
+ ```console
6
+ npm install --global @tonbo/cli
7
+ tonbo --version
8
+ ```
9
+
10
+ The V1 CLI deploys a PI-based Agent directly from a local directory without requiring Git or Tonbo calls in the Agent source. Add one JSON declaration at the directory root:
11
+
12
+ ```json
13
+ {
14
+ "version": 1,
15
+ "execution": {
16
+ "mode": "managed",
17
+ "runtime": "pi"
18
+ },
19
+ "inference": {
20
+ "model": "claude-sonnet-4-5"
21
+ },
22
+ "session_capture": {
23
+ "adapter": "pi-jsonl-v3"
24
+ }
25
+ }
26
+ ```
27
+
28
+ Then authenticate, bind the directory and deploy its current contents:
29
+
30
+ ```console
31
+ tonbo login
32
+ tonbo project create my-project
33
+ tonbo deploy
34
+ tonbo run "Reply with exactly READY."
35
+ ```
36
+
37
+ Use `tonbo project use my-project` instead when the Project already exists.
38
+
39
+ `run` creates a durable Session and prints its ID in `--json` output. Pass that identity back to continue the same PI conversation:
40
+
41
+ ```console
42
+ tonbo run --session 4d8e9501-... "Continue the previous task."
43
+ ```
44
+
45
+ Open the same singleton runtime and persistent `/workspace` interactively:
46
+
47
+ ```console
48
+ tonbo ssh
49
+ ```
50
+
51
+ `tonbo login` registers public keys found at the conventional `~/.ssh/id_ed25519.pub`, `id_ecdsa.pub` and `id_rsa.pub` paths. Register a key at another path with `tonbo ssh-key add <path.pub>` and revoke a lost or retired key with `tonbo ssh-key remove SHA256:...`. After that, the CLI is not part of the SSH connection; ordinary OpenSSH works directly:
52
+
53
+ ```console
54
+ ssh my-project@tonbo.sh
55
+ ```
56
+
57
+ The SSH username selects the globally unique Project; the signed public key identifies the Tonbo user and is checked against Project membership and IAM at connection time. The shell and HTTP turns may coexist on the singleton runtime, allowing the shell to observe the Agent while it works. Session writer fencing protects durable conversation history; ordinary workspace files retain Linux process concurrency semantics.
58
+
59
+ The platform injects scoped inference access and the managed PI runtime writes the declared session format through the session-aware filesystem. Do not put a model-provider API key or a Tonbo credential in `.tonbo`.
60
+
61
+ Deploy snapshots regular files under `.tonbo`, excluding `.git`, `node_modules`, local environment files and patterns in `.tonboignore`. Equal contents produce the same revision. Coding tools start in the persistent Artifacts workspace, while `.pi` extensions, skills and prompts load from the uploaded revision.
62
+
63
+ For a non-interactive invocation, provide a current Tonbo CLI OAuth access token and an explicit Project:
64
+
65
+ ```console
66
+ TONBO_ACCESS_TOKEN=... tonbo --json deploy --project project-id
67
+ TONBO_ACCESS_TOKEN=... tonbo --json run --project project-id "health check"
68
+ ```
69
+
70
+ Node.js 22 or newer is required. Human OAuth tokens are stored at `${XDG_CONFIG_HOME:-$HOME/.config}/tonbo/credentials.json`; the CLI atomically replaces that file with owner-only permissions and never writes it into an Agent directory.
71
+
72
+ ## Related design
73
+
74
+ - [Design documentation map](../../docs/design/README.md)
75
+ - [Project resource model](../../docs/design/project-resource-model.md)
76
+ - [ADR-0008: Tonbo CLI onboarding and credential boundaries](../../apps/user-center/docs/decisions/adr-0008-tonbo-cli-onboarding.md)
77
+ - [Management API v1](../../contracts/management/openapi-v1.yaml)
@@ -0,0 +1,70 @@
1
+ import type { ProjectSession, ProjectSessionTurn, ProjectSessionTurnEvent, ManagedRevisionSpec, ProjectSummary, SourceBundle } from "./types.js";
2
+ import type { SshPublicKey } from "./ssh-key.js";
3
+ interface RevisionDto {
4
+ id: string;
5
+ spec_sha256: string;
6
+ }
7
+ interface DeploymentDto {
8
+ desired_revision_id: string;
9
+ generation: number | string;
10
+ observed_state: string;
11
+ }
12
+ export declare class TonboApi {
13
+ private readonly fetcher;
14
+ private readonly accountOrigin;
15
+ private readonly managementOrigin;
16
+ constructor(fetcher: typeof fetch, accountOrigin?: string, managementOrigin?: string);
17
+ listProjects(oauthToken: string): Promise<ProjectSummary[]>;
18
+ createProject(oauthToken: string, slug: string, name?: string): Promise<ProjectSummary>;
19
+ registerSshKey(oauthToken: string, key: SshPublicKey): Promise<{
20
+ fingerprint: string;
21
+ }>;
22
+ revokeSshKey(oauthToken: string, fingerprint: string): Promise<{
23
+ fingerprint: string;
24
+ }>;
25
+ exchangeManagementToken(oauthToken: string, projectId: string): Promise<string>;
26
+ deploy({ bundle, projectId, spec, token, }: {
27
+ bundle: SourceBundle;
28
+ projectId: string;
29
+ spec: ManagedRevisionSpec;
30
+ token: string;
31
+ }): Promise<{
32
+ deployment: DeploymentDto;
33
+ revision: RevisionDto;
34
+ }>;
35
+ run({ projectId, prompt, sessionId, token, turnId, }: {
36
+ projectId: string;
37
+ prompt: string;
38
+ sessionId?: string;
39
+ token: string;
40
+ turnId?: string;
41
+ }): Promise<{
42
+ deployment: DeploymentDto;
43
+ session: ProjectSession | {
44
+ id: string;
45
+ };
46
+ turn: ProjectSessionTurn;
47
+ }>;
48
+ turnEvents({ after, projectId, sessionId, token, turnId, }: {
49
+ after?: number;
50
+ projectId: string;
51
+ sessionId: string;
52
+ token: string;
53
+ turnId: string;
54
+ }): Promise<{
55
+ data: ProjectSessionTurnEvent[];
56
+ status: "pending" | "completed" | "failed";
57
+ }>;
58
+ listProjectSecrets(projectId: string, token: string): Promise<{
59
+ name: string;
60
+ updated_at: string;
61
+ }[]>;
62
+ setProjectSecret(projectId: string, name: string, value: string, token: string): Promise<{
63
+ name: string;
64
+ updated_at: string;
65
+ }>;
66
+ deleteProjectSecret(projectId: string, name: string, token: string): Promise<null>;
67
+ private management;
68
+ private managementList;
69
+ }
70
+ export {};
@@ -0,0 +1,179 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { requestJson } from "./http.js";
3
+ function stableJson(value) {
4
+ if (value === null || typeof value !== "object")
5
+ return JSON.stringify(value);
6
+ if (Array.isArray(value))
7
+ return `[${value.map(stableJson).join(",")}]`;
8
+ const object = value;
9
+ return `{${Object.keys(object)
10
+ .sort()
11
+ .map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`)
12
+ .join(",")}}`;
13
+ }
14
+ function digest(value) {
15
+ return createHash("sha256").update(stableJson(value)).digest("hex");
16
+ }
17
+ export class TonboApi {
18
+ fetcher;
19
+ accountOrigin;
20
+ managementOrigin;
21
+ constructor(fetcher, accountOrigin = "https://tonbo.dev", managementOrigin = "https://api.tonbo.dev") {
22
+ this.fetcher = fetcher;
23
+ this.accountOrigin = accountOrigin;
24
+ this.managementOrigin = managementOrigin;
25
+ }
26
+ listProjects(oauthToken) {
27
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/projects`, {
28
+ headers: { authorization: `Bearer ${oauthToken}` },
29
+ }).then((body) => body.projects);
30
+ }
31
+ createProject(oauthToken, slug, name) {
32
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/projects`, {
33
+ method: "POST",
34
+ headers: {
35
+ authorization: `Bearer ${oauthToken}`,
36
+ "content-type": "application/json",
37
+ },
38
+ body: JSON.stringify({ slug, ...(name ? { name } : {}) }),
39
+ }).then((body) => body.project);
40
+ }
41
+ registerSshKey(oauthToken, key) {
42
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/ssh-keys`, {
43
+ method: "POST",
44
+ headers: {
45
+ authorization: `Bearer ${oauthToken}`,
46
+ "content-type": "application/json",
47
+ },
48
+ body: JSON.stringify({
49
+ algorithm: key.algorithm,
50
+ key_base64: key.keyBase64,
51
+ label: key.label,
52
+ }),
53
+ }).then((body) => body.key);
54
+ }
55
+ revokeSshKey(oauthToken, fingerprint) {
56
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/ssh-keys`, {
57
+ method: "DELETE",
58
+ headers: {
59
+ authorization: `Bearer ${oauthToken}`,
60
+ "content-type": "application/json",
61
+ },
62
+ body: JSON.stringify({ fingerprint }),
63
+ }).then((body) => body.key);
64
+ }
65
+ exchangeManagementToken(oauthToken, projectId) {
66
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/projects/${projectId}/token`, {
67
+ method: "POST",
68
+ headers: { authorization: `Bearer ${oauthToken}` },
69
+ }).then((body) => body.access_token);
70
+ }
71
+ async deploy({ bundle, projectId, spec, token, }) {
72
+ const descriptor = {
73
+ format: bundle.format,
74
+ sha256: bundle.sha256,
75
+ size_bytes: bundle.size_bytes,
76
+ };
77
+ const projectPath = `/v1/projects/${projectId}`;
78
+ const bundlesPath = `${projectPath}/source-bundles`;
79
+ const prepared = await this.management("PUT", `${bundlesPath}/${bundle.sha256}`, token, {
80
+ format: descriptor.format,
81
+ size_bytes: descriptor.size_bytes,
82
+ });
83
+ if (prepared.status === "upload") {
84
+ if (!prepared.upload_url)
85
+ throw new Error("Tonbo did not return a source upload URL.");
86
+ const uploaded = await this.fetcher(prepared.upload_url, {
87
+ method: "PUT",
88
+ headers: {
89
+ "content-type": prepared.content_type ?? "application/vnd.tonbo.source+tar",
90
+ "x-upsert": "false",
91
+ },
92
+ body: new Blob([new Uint8Array(bundle.bytes)]),
93
+ });
94
+ let uploadError = null;
95
+ if (!uploaded.ok) {
96
+ uploadError = new Error(`Source upload failed with HTTP ${uploaded.status}.`);
97
+ }
98
+ try {
99
+ await this.management("POST", `${bundlesPath}/${bundle.sha256}/complete`, token, {
100
+ format: bundle.format,
101
+ size_bytes: bundle.size_bytes,
102
+ });
103
+ }
104
+ catch (error) {
105
+ if (uploadError)
106
+ throw new AggregateError([uploadError, error], "Source bundle upload did not complete.");
107
+ throw error;
108
+ }
109
+ }
110
+ const revisionsPath = `${projectPath}/revisions`;
111
+ const revisionDigest = digest(spec);
112
+ const revisions = await this.managementList(revisionsPath, token);
113
+ let revision = revisions.find((candidate) => candidate.spec_sha256 === revisionDigest);
114
+ if (!revision) {
115
+ revision = (await this.management("POST", revisionsPath, token, { spec })).data;
116
+ }
117
+ const deploymentPath = `${projectPath}/deployment`;
118
+ const current = await this.management("GET", deploymentPath, token).catch((error) => {
119
+ if (error.status === 404)
120
+ return null;
121
+ throw error;
122
+ });
123
+ const deployment = (await this.management("PUT", deploymentPath, token, {
124
+ desired_revision_id: revision.id,
125
+ desired_state: "running",
126
+ expected_generation: current ? Number(current.data.generation) : null,
127
+ })).data;
128
+ return { deployment, revision };
129
+ }
130
+ async run({ projectId, prompt, sessionId, token, turnId = randomUUID(), }) {
131
+ const projectPath = `/v1/projects/${projectId}`;
132
+ const deployment = (await this.management("GET", `${projectPath}/deployment`, token)).data;
133
+ if (deployment.observed_state !== "running")
134
+ throw new Error(`Project Agent is ${deployment.observed_state}; wait for it to be running.`);
135
+ const session = sessionId
136
+ ? { id: sessionId }
137
+ : (await this.management("POST", `${projectPath}/sessions`, token, { revision_id: deployment.desired_revision_id })).data;
138
+ const turn = (await this.management("POST", `${projectPath}/sessions/${session.id}/turns`, token, { prompt }, turnId)).data;
139
+ return { deployment, session, turn };
140
+ }
141
+ turnEvents({ after = 0, projectId, sessionId, token, turnId, }) {
142
+ return this.management("GET", `/v1/projects/${projectId}/sessions/${sessionId}/turns/${turnId}/events?after=${after}`, token);
143
+ }
144
+ listProjectSecrets(projectId, token) {
145
+ return this.management("GET", `/v1/projects/${projectId}/secrets`, token).then((body) => body.data);
146
+ }
147
+ setProjectSecret(projectId, name, value, token) {
148
+ return this.management("PUT", `/v1/projects/${projectId}/secrets/${encodeURIComponent(name)}`, token, { value });
149
+ }
150
+ deleteProjectSecret(projectId, name, token) {
151
+ return this.management("DELETE", `/v1/projects/${projectId}/secrets/${encodeURIComponent(name)}`, token);
152
+ }
153
+ management(method, path, token, body, idempotencyKey) {
154
+ return requestJson(this.fetcher, `${this.managementOrigin}${path}`, {
155
+ method,
156
+ headers: {
157
+ authorization: `Bearer ${token}`,
158
+ ...(body === undefined
159
+ ? {}
160
+ : {
161
+ "content-type": "application/json",
162
+ "idempotency-key": idempotencyKey ?? randomUUID(),
163
+ }),
164
+ },
165
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
166
+ });
167
+ }
168
+ async managementList(path, token) {
169
+ const values = [];
170
+ let cursor = null;
171
+ do {
172
+ const separator = path.includes("?") ? "&" : "?";
173
+ const page = await this.management("GET", `${path}${separator}limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, token);
174
+ values.push(...page.data);
175
+ cursor = page.next_cursor;
176
+ } while (cursor);
177
+ return values;
178
+ }
179
+ }
@@ -0,0 +1,4 @@
1
+ import { Command } from "commander";
2
+ import { type CommandDependencies } from "./commands.js";
3
+ export declare function createDependencies(json?: boolean): CommandDependencies;
4
+ export declare function createProgram(dependencies?: typeof createDependencies): Command;
@@ -0,0 +1,99 @@
1
+ import { Command } from "commander";
2
+ import { readFileSync } from "node:fs";
3
+ import { TonboApi } from "./api.js";
4
+ import { AuthClient } from "./auth.js";
5
+ import { deployCommand, runCommand, loginCommand, projectCreateCommand, projectUseCommand, sshCommand, sshKeyAddCommand, sshKeyRemoveCommand, secretListCommand, secretRemoveCommand, secretSetCommand, } from "./commands.js";
6
+ import { FileConfigStore } from "./config.js";
7
+ import { FileCredentialStore } from "./credentials.js";
8
+ const packageVersion = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
9
+ export function createDependencies(json = false) {
10
+ const accountOrigin = process.env.TONBO_ACCOUNT_ORIGIN || "https://tonbo.dev";
11
+ const managementOrigin = process.env.TONBO_API_ORIGIN || "https://api.tonbo.dev";
12
+ return {
13
+ api: new TonboApi(fetch, accountOrigin, managementOrigin),
14
+ auth: new AuthClient(new FileCredentialStore(), fetch, accountOrigin),
15
+ config: new FileConfigStore(),
16
+ cwd: () => process.cwd(),
17
+ output: (value) => {
18
+ if (json)
19
+ console.log(JSON.stringify(value));
20
+ else
21
+ console.log(value.message ?? value);
22
+ },
23
+ secretValue: async (name, fromEnvironment) => {
24
+ const environmentName = fromEnvironment ?? name;
25
+ const value = process.env[environmentName];
26
+ if (!value)
27
+ throw new Error(`Environment variable ${environmentName} is empty. Set it before running tonbo secret set.`);
28
+ return value;
29
+ },
30
+ };
31
+ }
32
+ export function createProgram(dependencies = createDependencies) {
33
+ const program = new Command()
34
+ .name("tonbo")
35
+ .description("Deploy a persistent Project Agent to Tonbo.")
36
+ .version(packageVersion.version)
37
+ .option("--json", "print machine-readable JSON");
38
+ program
39
+ .command("login")
40
+ .description("sign in through the browser and store the session in the user config")
41
+ .action(async () => loginCommand(dependencies(program.opts().json)));
42
+ const project = program
43
+ .command("project")
44
+ .description("manage the Project bound to this Agent directory");
45
+ project
46
+ .command("create <slug>")
47
+ .description("create and bind a Project to this Agent directory")
48
+ .option("--name <name>", "display name for the Project")
49
+ .action(async (slug, options) => projectCreateCommand(dependencies(program.opts().json), slug, options.name));
50
+ const sshKey = program
51
+ .command("ssh-key")
52
+ .description("manage public keys used by native Project SSH");
53
+ sshKey
54
+ .command("add <public-key>")
55
+ .description("register an OpenSSH public key with the current Tonbo account")
56
+ .action(async (path) => sshKeyAddCommand(dependencies(program.opts().json), path));
57
+ sshKey
58
+ .command("remove <fingerprint>")
59
+ .description("revoke an SSH public key from the current Tonbo account")
60
+ .action(async (fingerprint) => sshKeyRemoveCommand(dependencies(program.opts().json), fingerprint));
61
+ project
62
+ .command("use <project>")
63
+ .description("bind this .tonbo directory to a Project slug or ID")
64
+ .action(async (selector) => projectUseCommand(dependencies(program.opts().json), selector));
65
+ program
66
+ .command("deploy")
67
+ .description("upload this directory as the selected Project deployment")
68
+ .option("--project <project>", "override the bound Project for this deploy")
69
+ .action(async (options) => deployCommand(dependencies(program.opts().json), options.project));
70
+ program
71
+ .command("run <prompt>")
72
+ .description("run one prompt in a durable Project session")
73
+ .option("--project <project>", "override the bound Project for this turn")
74
+ .option("--session <session>", "resume an existing Agent Session UUID")
75
+ .action(async (prompt, options) => runCommand(dependencies(program.opts().json), prompt, options));
76
+ program
77
+ .command("ssh")
78
+ .description("open the selected Project's singleton runtime over SSH")
79
+ .option("--project <project>", "override the bound Project")
80
+ .action(async (options) => sshCommand(dependencies(program.opts().json), options.project));
81
+ const secret = program
82
+ .command("secret")
83
+ .description("manage encrypted environment secrets for the Project service");
84
+ secret
85
+ .command("list")
86
+ .option("--project <project>", "override the bound Project")
87
+ .action(async (options) => secretListCommand(dependencies(program.opts().json), options.project));
88
+ secret
89
+ .command("set <name>")
90
+ .description("set a secret from an environment variable (the same name by default)")
91
+ .option("--from-env <name>", "read the value from another environment variable")
92
+ .option("--project <project>", "override the bound Project")
93
+ .action(async (name, options) => secretSetCommand(dependencies(program.opts().json), name, options));
94
+ secret
95
+ .command("remove <name>")
96
+ .option("--project <project>", "override the bound Project")
97
+ .action(async (name, options) => secretRemoveCommand(dependencies(program.opts().json), name, options.project));
98
+ return program;
99
+ }
@@ -0,0 +1,13 @@
1
+ import type { CredentialStore } from "./credentials.js";
2
+ import type { OAuthTokenSet } from "./types.js";
3
+ export declare class AuthClient {
4
+ private readonly credentials;
5
+ private readonly fetcher;
6
+ private readonly accountOrigin;
7
+ constructor(credentials: CredentialStore, fetcher: typeof fetch, accountOrigin?: string);
8
+ accessToken(): Promise<string>;
9
+ login(): Promise<OAuthTokenSet>;
10
+ private config;
11
+ private exchange;
12
+ private listenForCode;
13
+ }
@@ -0,0 +1,147 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
5
+ import { callbackResponse } from "./callback-page.js";
6
+ import { requestJson } from "./http.js";
7
+ const exec = promisify(execFile);
8
+ const LOOPBACK_REDIRECT = "http://localhost:17655/callback";
9
+ function base64url(value) {
10
+ return value.toString("base64url");
11
+ }
12
+ function withAbsoluteExpiry(tokens) {
13
+ if (tokens.expires_at || !Number.isInteger(tokens.expires_in) || (tokens.expires_in ?? 0) <= 0)
14
+ return tokens;
15
+ return {
16
+ ...tokens,
17
+ expires_at: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 0),
18
+ };
19
+ }
20
+ export class AuthClient {
21
+ credentials;
22
+ fetcher;
23
+ accountOrigin;
24
+ constructor(credentials, fetcher, accountOrigin = "https://tonbo.dev") {
25
+ this.credentials = credentials;
26
+ this.fetcher = fetcher;
27
+ this.accountOrigin = accountOrigin;
28
+ }
29
+ async accessToken() {
30
+ const injected = process.env.TONBO_ACCESS_TOKEN?.trim();
31
+ if (injected)
32
+ return injected;
33
+ const tokens = await this.credentials.load();
34
+ if (!tokens)
35
+ throw new Error("Not logged in. Run `tonbo login`.");
36
+ // Older CLI releases stored OAuth's relative `expires_in` without the
37
+ // issue time, so its remaining lifetime cannot be reconstructed. Refresh
38
+ // that shape once instead of treating the access token as immortal.
39
+ const legacyRelativeExpiry = !tokens.expires_at && tokens.expires_in;
40
+ if (!legacyRelativeExpiry &&
41
+ (!tokens.expires_at || tokens.expires_at > Math.floor(Date.now() / 1000) + 30))
42
+ return tokens.access_token;
43
+ if (!tokens.refresh_token)
44
+ throw new Error("Tonbo login expired. Run `tonbo login` again.");
45
+ const config = await this.config();
46
+ const refreshed = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
47
+ client_id: config.client_id,
48
+ grant_type: "refresh_token",
49
+ refresh_token: tokens.refresh_token,
50
+ })));
51
+ if (!refreshed.refresh_token)
52
+ refreshed.refresh_token = tokens.refresh_token;
53
+ await this.credentials.save(refreshed);
54
+ return refreshed.access_token;
55
+ }
56
+ async login() {
57
+ const config = await this.config();
58
+ if (config.redirect_uri !== LOOPBACK_REDIRECT)
59
+ throw new Error(`Unsupported OAuth redirect URI: ${config.redirect_uri}`);
60
+ const verifier = base64url(randomBytes(48));
61
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
62
+ const state = base64url(randomBytes(24));
63
+ const authorization = new URL(config.authorization_endpoint);
64
+ authorization.search = new URLSearchParams({
65
+ client_id: config.client_id,
66
+ code_challenge: challenge,
67
+ code_challenge_method: "S256",
68
+ redirect_uri: config.redirect_uri,
69
+ response_type: "code",
70
+ scope: "openid profile email",
71
+ state,
72
+ }).toString();
73
+ const code = await this.listenForCode(state, () => openBrowser(authorization.toString()));
74
+ const tokens = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
75
+ client_id: config.client_id,
76
+ code,
77
+ code_verifier: verifier,
78
+ grant_type: "authorization_code",
79
+ redirect_uri: config.redirect_uri,
80
+ })));
81
+ await this.credentials.save(tokens);
82
+ return tokens;
83
+ }
84
+ config() {
85
+ return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/config`);
86
+ }
87
+ exchange(endpoint, body) {
88
+ return requestJson(this.fetcher, endpoint, {
89
+ method: "POST",
90
+ headers: { "content-type": "application/x-www-form-urlencoded" },
91
+ body,
92
+ });
93
+ }
94
+ listenForCode(expectedState, ready) {
95
+ return new Promise((resolve, reject) => {
96
+ let settled = false;
97
+ const finish = (result) => {
98
+ if (settled)
99
+ return;
100
+ settled = true;
101
+ clearTimeout(timeout);
102
+ server.close();
103
+ if ("code" in result)
104
+ resolve(result.code);
105
+ else
106
+ reject(result.error);
107
+ };
108
+ const timeout = setTimeout(() => {
109
+ finish({ error: new Error("Timed out waiting for browser login.") });
110
+ }, 5 * 60 * 1000);
111
+ const server = createServer((request, response) => {
112
+ const url = new URL(request.url ?? "/", LOOPBACK_REDIRECT);
113
+ const code = url.searchParams.get("code");
114
+ const oauthError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
115
+ if (oauthError) {
116
+ respond(response, "denied");
117
+ finish({ error: new Error(oauthError) });
118
+ return;
119
+ }
120
+ if (url.pathname !== "/callback" ||
121
+ url.searchParams.get("state") !== expectedState ||
122
+ !code) {
123
+ respond(response, "invalid");
124
+ return;
125
+ }
126
+ respond(response, "complete");
127
+ finish({ code });
128
+ });
129
+ server.once("error", (error) => finish({ error }));
130
+ server.listen(17655, "localhost", () => void ready().catch((error) => finish({
131
+ error: error instanceof Error ? error : new Error("Could not open the login browser."),
132
+ })));
133
+ });
134
+ }
135
+ }
136
+ function respond(response, outcome) {
137
+ const page = callbackResponse(outcome);
138
+ response.writeHead(page.status, page.headers);
139
+ response.end(page.body);
140
+ }
141
+ async function openBrowser(url) {
142
+ if (process.platform === "darwin")
143
+ return void (await exec("open", [url]));
144
+ if (process.platform === "win32")
145
+ return void (await exec("rundll32", ["url.dll,FileProtocolHandler", url]));
146
+ return void (await exec("xdg-open", [url]));
147
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The page a browser lands on after `tonbo login`.
3
+ *
4
+ * This is served by a loopback HTTP server inside the CLI, so it cannot reach
5
+ * the app's stylesheet or webfonts. It is self-contained on purpose: no
6
+ * external request, which also means it renders offline and leaks nothing
7
+ * about the login to a third party.
8
+ *
9
+ * Nothing from the query string is interpolated here. The callback URL is
10
+ * reachable by anything that can make the user's browser open a localhost
11
+ * address, so reflecting `error_description` would put attacker-chosen text
12
+ * into a page served from the user's own machine.
13
+ */
14
+ export type CallbackOutcome = "complete" | "denied" | "invalid";
15
+ export declare function renderCallbackPage(outcome: CallbackOutcome): string;
16
+ export declare function callbackResponse(outcome: CallbackOutcome): {
17
+ status: number;
18
+ headers: {
19
+ "content-type": string;
20
+ "cache-control": string;
21
+ };
22
+ body: string;
23
+ };