@tonbo/cli 0.0.5 → 0.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.0.5",
3
+ "version": "0.0.7",
4
4
  "description": "Deploy one persistent Agent per Project from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {
@@ -13,23 +13,24 @@
13
13
  },
14
14
  "type": "module",
15
15
  "bin": {
16
- "tonbo": "dist/src/main.js"
16
+ "tonbo": "dist/bin/tonbo.js"
17
17
  },
18
18
  "files": [
19
- "dist/src"
19
+ "dist/bin"
20
20
  ],
21
21
  "publishConfig": {
22
22
  "access": "public"
23
23
  },
24
24
  "scripts": {
25
- "build": "node scripts/generate-contracts.mjs --check && tsc -p tsconfig.json",
25
+ "build": "pnpm --filter @tonbo/agent-source-inspector build && node scripts/generate-contracts.mjs --check && tsc -p tsconfig.json && node scripts/bundle.mjs",
26
26
  "format:check": "prettier --ignore-path ../../.prettierignore --check \"**/*.{js,mjs,cjs,jsx,ts,tsx}\"",
27
27
  "generate:contracts": "node scripts/generate-contracts.mjs",
28
28
  "lint": "eslint . --max-warnings=0",
29
29
  "test": "pnpm build && node --test dist/test/*.test.js",
30
- "typecheck": "node scripts/generate-contracts.mjs --check && tsc -p tsconfig.json --noEmit"
30
+ "typecheck": "pnpm --filter @tonbo/agent-source-inspector build && node scripts/generate-contracts.mjs --check && tsc -p tsconfig.json --noEmit"
31
31
  },
32
32
  "dependencies": {
33
+ "@inquirer/select": "4.4.2",
33
34
  "ajv": "8.20.0",
34
35
  "commander": "^14.0.3",
35
36
  "ignore": "^7.0.5",
@@ -37,8 +38,10 @@
37
38
  "tar-stream": "^3.1.7"
38
39
  },
39
40
  "devDependencies": {
41
+ "@tonbo/agent-source-inspector": "workspace:*",
40
42
  "@types/node": "^20.19.37",
41
43
  "@types/tar-stream": "^3.1.4",
44
+ "esbuild": "0.25.4",
42
45
  "typescript": "^5.9.3"
43
46
  },
44
47
  "engines": {
package/dist/src/api.d.ts DELETED
@@ -1,70 +0,0 @@
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 {};
package/dist/src/api.js DELETED
@@ -1,179 +0,0 @@
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
- }
package/dist/src/app.d.ts DELETED
@@ -1,4 +0,0 @@
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;
package/dist/src/app.js DELETED
@@ -1,113 +0,0 @@
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, initCommand, 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
- import { DECLARATION_FILENAME } from "./declaration.js";
9
- import { silentProgress, TerminalProgress } from "./progress.js";
10
- import { terminalPrompt } from "./prompt.js";
11
- import { readDefaultSshPublicKeys } from "./ssh-key.js";
12
- const packageVersion = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
13
- export function createDependencies(json = false) {
14
- const accountOrigin = process.env.TONBO_ACCOUNT_ORIGIN || "https://tonbo.dev";
15
- const managementOrigin = process.env.TONBO_API_ORIGIN || "https://api.tonbo.dev";
16
- return {
17
- api: new TonboApi(fetch, accountOrigin, managementOrigin),
18
- auth: new AuthClient(new FileCredentialStore(), fetch, accountOrigin),
19
- config: new FileConfigStore(),
20
- cwd: () => process.cwd(),
21
- defaultSshPublicKeys: readDefaultSshPublicKeys,
22
- interactive: () => !json && process.stdin.isTTY === true && process.stderr.isTTY === true,
23
- output: (value) => {
24
- if (json)
25
- console.log(JSON.stringify(value));
26
- else
27
- console.log(value.message ?? value);
28
- },
29
- progress: json ? silentProgress : new TerminalProgress(process.stderr),
30
- prompt: terminalPrompt,
31
- secretValue: async (name, fromEnvironment) => {
32
- const environmentName = fromEnvironment ?? name;
33
- const value = process.env[environmentName];
34
- if (!value)
35
- throw new Error(`Environment variable ${environmentName} is empty. Set it before running tonbo secret set.`);
36
- return value;
37
- },
38
- };
39
- }
40
- export function createProgram(dependencies = createDependencies) {
41
- const program = new Command()
42
- .name("tonbo")
43
- .description("Deploy a persistent Project Agent to Tonbo.")
44
- .version(packageVersion.version)
45
- .option("--json", "print machine-readable JSON");
46
- program
47
- .command("init")
48
- .description("interactively create a Tonbo Agent declaration in this directory")
49
- .option("--model <model>", "inference model")
50
- .option("--force", `replace an existing ${DECLARATION_FILENAME}`)
51
- .action(async (options) => initCommand(dependencies(program.opts().json), options));
52
- program
53
- .command("login")
54
- .description("sign in through the browser and store the session in the user config")
55
- .action(async () => loginCommand(dependencies(program.opts().json)));
56
- const project = program
57
- .command("project")
58
- .description("manage the Project bound to this Agent directory");
59
- project
60
- .command("create <slug>")
61
- .description("create and bind a Project to this Agent directory")
62
- .option("--name <name>", "display name for the Project")
63
- .action(async (slug, options) => projectCreateCommand(dependencies(program.opts().json), slug, options.name));
64
- const sshKey = program
65
- .command("ssh-key")
66
- .description("manage public keys used by native Project SSH");
67
- sshKey
68
- .command("add <public-key>")
69
- .description("register an OpenSSH public key with the current Tonbo account")
70
- .action(async (path) => sshKeyAddCommand(dependencies(program.opts().json), path));
71
- sshKey
72
- .command("remove <fingerprint>")
73
- .description("revoke an SSH public key from the current Tonbo account")
74
- .action(async (fingerprint) => sshKeyRemoveCommand(dependencies(program.opts().json), fingerprint));
75
- project
76
- .command("use <project>")
77
- .description("bind this .tonbo directory to a Project slug or ID")
78
- .action(async (selector) => projectUseCommand(dependencies(program.opts().json), selector));
79
- program
80
- .command("deploy")
81
- .description("upload this directory as the selected Project deployment")
82
- .option("--project <project>", "override the bound Project for this deploy")
83
- .action(async (options) => deployCommand(dependencies(program.opts().json), options.project));
84
- program
85
- .command("run <prompt>")
86
- .description("run one prompt in a durable Project session")
87
- .option("--project <project>", "override the bound Project for this turn")
88
- .option("--session <session>", "resume an existing Agent Session UUID")
89
- .action(async (prompt, options) => runCommand(dependencies(program.opts().json), prompt, options));
90
- program
91
- .command("ssh")
92
- .description("open the selected Project's singleton runtime over SSH")
93
- .option("--project <project>", "override the bound Project")
94
- .action(async (options) => sshCommand(dependencies(program.opts().json), options.project));
95
- const secret = program
96
- .command("secret")
97
- .description("manage encrypted environment secrets for the Project service");
98
- secret
99
- .command("list")
100
- .option("--project <project>", "override the bound Project")
101
- .action(async (options) => secretListCommand(dependencies(program.opts().json), options.project));
102
- secret
103
- .command("set <name>")
104
- .description("set a secret from an environment variable (the same name by default)")
105
- .option("--from-env <name>", "read the value from another environment variable")
106
- .option("--project <project>", "override the bound Project")
107
- .action(async (name, options) => secretSetCommand(dependencies(program.opts().json), name, options));
108
- secret
109
- .command("remove <name>")
110
- .option("--project <project>", "override the bound Project")
111
- .action(async (name, options) => secretRemoveCommand(dependencies(program.opts().json), name, options.project));
112
- return program;
113
- }
@@ -1,20 +0,0 @@
1
- import type { CredentialStore } from "./credentials.js";
2
- import type { OAuthTokenSet } from "./types.js";
3
- export type LoginStep = "account-config" | "callback-server" | "browser" | "browser-authorization" | "callback-close" | "token-exchange" | "credential-store";
4
- export interface LoginProgressEvent {
5
- status: "completed" | "started";
6
- step: LoginStep;
7
- }
8
- export type LoginProgress = (event: LoginProgressEvent) => void;
9
- export declare class AuthClient {
10
- private readonly credentials;
11
- private readonly fetcher;
12
- private readonly accountOrigin;
13
- private readonly browserOpener;
14
- constructor(credentials: CredentialStore, fetcher: typeof fetch, accountOrigin?: string, browserOpener?: (url: string) => Promise<void>);
15
- accessToken(): Promise<string>;
16
- login(progress?: LoginProgress): Promise<OAuthTokenSet>;
17
- private config;
18
- private exchange;
19
- private listenForCode;
20
- }
package/dist/src/auth.js DELETED
@@ -1,196 +0,0 @@
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
- browserOpener;
25
- constructor(credentials, fetcher, accountOrigin = "https://tonbo.dev", browserOpener = openBrowser) {
26
- this.credentials = credentials;
27
- this.fetcher = fetcher;
28
- this.accountOrigin = accountOrigin;
29
- this.browserOpener = browserOpener;
30
- }
31
- async accessToken() {
32
- const injected = process.env.TONBO_ACCESS_TOKEN?.trim();
33
- if (injected)
34
- return injected;
35
- const tokens = await this.credentials.load();
36
- if (!tokens)
37
- throw new Error("Not logged in. Run `tonbo login`.");
38
- // Older CLI releases stored OAuth's relative `expires_in` without the
39
- // issue time, so its remaining lifetime cannot be reconstructed. Refresh
40
- // that shape once instead of treating the access token as immortal.
41
- const legacyRelativeExpiry = !tokens.expires_at && tokens.expires_in;
42
- if (!legacyRelativeExpiry &&
43
- (!tokens.expires_at || tokens.expires_at > Math.floor(Date.now() / 1000) + 30))
44
- return tokens.access_token;
45
- if (!tokens.refresh_token)
46
- throw new Error("Tonbo login expired. Run `tonbo login` again.");
47
- const config = await this.config();
48
- const refreshed = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
49
- client_id: config.client_id,
50
- grant_type: "refresh_token",
51
- refresh_token: tokens.refresh_token,
52
- })));
53
- if (!refreshed.refresh_token)
54
- refreshed.refresh_token = tokens.refresh_token;
55
- await this.credentials.save(refreshed);
56
- return refreshed.access_token;
57
- }
58
- async login(progress = () => { }) {
59
- progress({ status: "started", step: "account-config" });
60
- const config = await this.config();
61
- progress({ status: "completed", step: "account-config" });
62
- if (config.redirect_uri !== LOOPBACK_REDIRECT)
63
- throw new Error(`Unsupported OAuth redirect URI: ${config.redirect_uri}`);
64
- const verifier = base64url(randomBytes(48));
65
- const challenge = createHash("sha256").update(verifier).digest("base64url");
66
- const state = base64url(randomBytes(24));
67
- const authorization = new URL(config.authorization_endpoint);
68
- authorization.search = new URLSearchParams({
69
- client_id: config.client_id,
70
- code_challenge: challenge,
71
- code_challenge_method: "S256",
72
- redirect_uri: config.redirect_uri,
73
- response_type: "code",
74
- scope: "openid profile email",
75
- state,
76
- }).toString();
77
- const code = await this.listenForCode(state, () => this.browserOpener(authorization.toString()), progress);
78
- progress({ status: "started", step: "token-exchange" });
79
- const tokens = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
80
- client_id: config.client_id,
81
- code,
82
- code_verifier: verifier,
83
- grant_type: "authorization_code",
84
- redirect_uri: config.redirect_uri,
85
- })));
86
- progress({ status: "completed", step: "token-exchange" });
87
- progress({ status: "started", step: "credential-store" });
88
- await this.credentials.save(tokens);
89
- progress({ status: "completed", step: "credential-store" });
90
- return tokens;
91
- }
92
- config() {
93
- return requestJson(this.fetcher, `${this.accountOrigin}/api/cli/config`);
94
- }
95
- exchange(endpoint, body) {
96
- return requestJson(this.fetcher, endpoint, {
97
- method: "POST",
98
- headers: { "content-type": "application/x-www-form-urlencoded" },
99
- body,
100
- });
101
- }
102
- listenForCode(expectedState, ready, progress) {
103
- return new Promise((resolve, reject) => {
104
- let settled = false;
105
- const sockets = new Set();
106
- let authorizationStarted = false;
107
- const startAuthorization = () => {
108
- if (authorizationStarted)
109
- return;
110
- authorizationStarted = true;
111
- progress({ status: "completed", step: "browser" });
112
- progress({ status: "started", step: "browser-authorization" });
113
- };
114
- const finish = (result, completedResponseSocket) => {
115
- if (settled)
116
- return;
117
- settled = true;
118
- clearTimeout(timeout);
119
- const complete = (closeError) => {
120
- if (closeError)
121
- reject(closeError);
122
- else if ("code" in result) {
123
- progress({ status: "completed", step: "callback-close" });
124
- resolve(result.code);
125
- }
126
- else
127
- reject(result.error);
128
- };
129
- if ("code" in result)
130
- progress({ status: "started", step: "callback-close" });
131
- if (!server.listening) {
132
- complete();
133
- return;
134
- }
135
- server.close(complete);
136
- for (const socket of sockets) {
137
- if (socket !== completedResponseSocket)
138
- socket.destroy();
139
- }
140
- server.closeIdleConnections();
141
- };
142
- const timeout = setTimeout(() => {
143
- finish({ error: new Error("Timed out waiting for browser login.") });
144
- }, 5 * 60 * 1000);
145
- const server = createServer((request, response) => {
146
- const url = new URL(request.url ?? "/", LOOPBACK_REDIRECT);
147
- const code = url.searchParams.get("code");
148
- const oauthError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
149
- if (oauthError) {
150
- const responseSocket = response.socket;
151
- respond(response, "denied", () => finish({ error: new Error(oauthError) }, responseSocket));
152
- return;
153
- }
154
- if (url.pathname !== "/callback" ||
155
- url.searchParams.get("state") !== expectedState ||
156
- !code) {
157
- respond(response, "invalid");
158
- return;
159
- }
160
- startAuthorization();
161
- progress({ status: "completed", step: "browser-authorization" });
162
- const responseSocket = response.socket;
163
- respond(response, "complete", () => finish({ code }, responseSocket));
164
- });
165
- server.on("connection", (socket) => {
166
- sockets.add(socket);
167
- socket.once("close", () => sockets.delete(socket));
168
- });
169
- server.once("error", (error) => finish({ error }));
170
- progress({ status: "started", step: "callback-server" });
171
- server.listen(17655, "localhost", () => {
172
- progress({ status: "completed", step: "callback-server" });
173
- progress({ status: "started", step: "browser" });
174
- void ready()
175
- .then(startAuthorization)
176
- .catch((error) => finish({
177
- error: error instanceof Error ? error : new Error("Could not open the login browser."),
178
- }));
179
- });
180
- });
181
- }
182
- }
183
- function respond(response, outcome, finished) {
184
- const page = callbackResponse(outcome);
185
- if (finished)
186
- response.once("finish", finished);
187
- response.writeHead(page.status, { ...page.headers, connection: "close" });
188
- response.end(page.body);
189
- }
190
- async function openBrowser(url) {
191
- if (process.platform === "darwin")
192
- return void (await exec("open", [url]));
193
- if (process.platform === "win32")
194
- return void (await exec("rundll32", ["url.dll,FileProtocolHandler", url]));
195
- return void (await exec("xdg-open", [url]));
196
- }
@@ -1,23 +0,0 @@
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
- };