@tonbo/cli 0.0.1 → 0.0.3

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/dist/src/app.js CHANGED
@@ -5,6 +5,8 @@ import { AuthClient } from "./auth.js";
5
5
  import { deployCommand, runCommand, loginCommand, projectCreateCommand, projectUseCommand, sshCommand, sshKeyAddCommand, sshKeyRemoveCommand, secretListCommand, secretRemoveCommand, secretSetCommand, } from "./commands.js";
6
6
  import { FileConfigStore } from "./config.js";
7
7
  import { FileCredentialStore } from "./credentials.js";
8
+ import { silentProgress, TerminalProgress } from "./progress.js";
9
+ import { readDefaultSshPublicKeys } from "./ssh-key.js";
8
10
  const packageVersion = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"));
9
11
  export function createDependencies(json = false) {
10
12
  const accountOrigin = process.env.TONBO_ACCOUNT_ORIGIN || "https://tonbo.dev";
@@ -14,12 +16,14 @@ export function createDependencies(json = false) {
14
16
  auth: new AuthClient(new FileCredentialStore(), fetch, accountOrigin),
15
17
  config: new FileConfigStore(),
16
18
  cwd: () => process.cwd(),
19
+ defaultSshPublicKeys: readDefaultSshPublicKeys,
17
20
  output: (value) => {
18
21
  if (json)
19
22
  console.log(JSON.stringify(value));
20
23
  else
21
24
  console.log(value.message ?? value);
22
25
  },
26
+ progress: json ? silentProgress : new TerminalProgress(process.stderr),
23
27
  secretValue: async (name, fromEnvironment) => {
24
28
  const environmentName = fromEnvironment ?? name;
25
29
  const value = process.env[environmentName];
@@ -1,12 +1,19 @@
1
1
  import type { CredentialStore } from "./credentials.js";
2
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;
3
9
  export declare class AuthClient {
4
10
  private readonly credentials;
5
11
  private readonly fetcher;
6
12
  private readonly accountOrigin;
7
- constructor(credentials: CredentialStore, fetcher: typeof fetch, accountOrigin?: string);
13
+ private readonly browserOpener;
14
+ constructor(credentials: CredentialStore, fetcher: typeof fetch, accountOrigin?: string, browserOpener?: (url: string) => Promise<void>);
8
15
  accessToken(): Promise<string>;
9
- login(): Promise<OAuthTokenSet>;
16
+ login(progress?: LoginProgress): Promise<OAuthTokenSet>;
10
17
  private config;
11
18
  private exchange;
12
19
  private listenForCode;
package/dist/src/auth.js CHANGED
@@ -21,10 +21,12 @@ export class AuthClient {
21
21
  credentials;
22
22
  fetcher;
23
23
  accountOrigin;
24
- constructor(credentials, fetcher, accountOrigin = "https://tonbo.dev") {
24
+ browserOpener;
25
+ constructor(credentials, fetcher, accountOrigin = "https://tonbo.dev", browserOpener = openBrowser) {
25
26
  this.credentials = credentials;
26
27
  this.fetcher = fetcher;
27
28
  this.accountOrigin = accountOrigin;
29
+ this.browserOpener = browserOpener;
28
30
  }
29
31
  async accessToken() {
30
32
  const injected = process.env.TONBO_ACCESS_TOKEN?.trim();
@@ -53,8 +55,10 @@ export class AuthClient {
53
55
  await this.credentials.save(refreshed);
54
56
  return refreshed.access_token;
55
57
  }
56
- async login() {
58
+ async login(progress = () => { }) {
59
+ progress({ status: "started", step: "account-config" });
57
60
  const config = await this.config();
61
+ progress({ status: "completed", step: "account-config" });
58
62
  if (config.redirect_uri !== LOOPBACK_REDIRECT)
59
63
  throw new Error(`Unsupported OAuth redirect URI: ${config.redirect_uri}`);
60
64
  const verifier = base64url(randomBytes(48));
@@ -70,7 +74,8 @@ export class AuthClient {
70
74
  scope: "openid profile email",
71
75
  state,
72
76
  }).toString();
73
- const code = await this.listenForCode(state, () => openBrowser(authorization.toString()));
77
+ const code = await this.listenForCode(state, () => this.browserOpener(authorization.toString()), progress);
78
+ progress({ status: "started", step: "token-exchange" });
74
79
  const tokens = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
75
80
  client_id: config.client_id,
76
81
  code,
@@ -78,7 +83,10 @@ export class AuthClient {
78
83
  grant_type: "authorization_code",
79
84
  redirect_uri: config.redirect_uri,
80
85
  })));
86
+ progress({ status: "completed", step: "token-exchange" });
87
+ progress({ status: "started", step: "credential-store" });
81
88
  await this.credentials.save(tokens);
89
+ progress({ status: "completed", step: "credential-store" });
82
90
  return tokens;
83
91
  }
84
92
  config() {
@@ -91,19 +99,45 @@ export class AuthClient {
91
99
  body,
92
100
  });
93
101
  }
94
- listenForCode(expectedState, ready) {
102
+ listenForCode(expectedState, ready, progress) {
95
103
  return new Promise((resolve, reject) => {
96
104
  let settled = false;
97
- const finish = (result) => {
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) => {
98
115
  if (settled)
99
116
  return;
100
117
  settled = true;
101
118
  clearTimeout(timeout);
102
- server.close();
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
+ };
103
129
  if ("code" in result)
104
- resolve(result.code);
105
- else
106
- reject(result.error);
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();
107
141
  };
108
142
  const timeout = setTimeout(() => {
109
143
  finish({ error: new Error("Timed out waiting for browser login.") });
@@ -113,8 +147,8 @@ export class AuthClient {
113
147
  const code = url.searchParams.get("code");
114
148
  const oauthError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
115
149
  if (oauthError) {
116
- respond(response, "denied");
117
- finish({ error: new Error(oauthError) });
150
+ const responseSocket = response.socket;
151
+ respond(response, "denied", () => finish({ error: new Error(oauthError) }, responseSocket));
118
152
  return;
119
153
  }
120
154
  if (url.pathname !== "/callback" ||
@@ -123,19 +157,34 @@ export class AuthClient {
123
157
  respond(response, "invalid");
124
158
  return;
125
159
  }
126
- respond(response, "complete");
127
- finish({ code });
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));
128
168
  });
129
169
  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
- })));
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
+ });
133
180
  });
134
181
  }
135
182
  }
136
- function respond(response, outcome) {
183
+ function respond(response, outcome, finished) {
137
184
  const page = callbackResponse(outcome);
138
- response.writeHead(page.status, page.headers);
185
+ if (finished)
186
+ response.once("finish", finished);
187
+ response.writeHead(page.status, { ...page.headers, connection: "close" });
139
188
  response.end(page.body);
140
189
  }
141
190
  async function openBrowser(url) {
@@ -2,12 +2,16 @@ import type { AuthClient } from "./auth.js";
2
2
  import type { TonboApi } from "./api.js";
3
3
  import type { ConfigStore } from "./config.js";
4
4
  import type { ProjectSummary } from "./types.js";
5
+ import { readDefaultSshPublicKeys } from "./ssh-key.js";
6
+ import type { ProgressReporter } from "./progress.js";
5
7
  export interface CommandDependencies {
6
8
  api: TonboApi;
7
9
  auth: AuthClient;
8
10
  config: ConfigStore;
9
11
  cwd: () => string;
12
+ defaultSshPublicKeys: typeof readDefaultSshPublicKeys;
10
13
  output: (value: unknown) => void;
14
+ progress: ProgressReporter;
11
15
  executable?: () => string;
12
16
  secretValue: (name: string, fromEnvironment?: string) => Promise<string>;
13
17
  }
@@ -1,6 +1,6 @@
1
1
  import { buildRevision, loadDeclaration } from "./declaration.js";
2
2
  import { buildSourceBundle, findDeclarationRoot } from "./source.js";
3
- import { readDefaultSshPublicKeys, readSshPublicKey } from "./ssh-key.js";
3
+ import { readSshPublicKey } from "./ssh-key.js";
4
4
  import { launchProjectSsh } from "./ssh.js";
5
5
  export async function resolveProject(deps, selector) {
6
6
  const oauthToken = await deps.auth.accessToken();
@@ -37,16 +37,55 @@ export function selectProject(projects, selector) {
37
37
  return matches[0];
38
38
  }
39
39
  export async function loginCommand(deps) {
40
- await deps.auth.login();
41
- const oauthToken = await deps.auth.accessToken();
42
- const keys = await readDefaultSshPublicKeys();
43
- for (const key of keys)
44
- await deps.api.registerSshKey(oauthToken, key);
45
- deps.output({
46
- message: keys.length
47
- ? `Logged in to Tonbo and registered ${keys.length} SSH key${keys.length === 1 ? "" : "s"}.`
48
- : "Logged in to Tonbo. No default SSH public key was found; run `tonbo ssh-key add <path.pub>` before using native SSH.",
49
- });
40
+ try {
41
+ const tokens = await deps.auth.login((event) => reportLoginProgress(deps.progress, event));
42
+ deps.progress.start("Preparing SSH access");
43
+ const keys = await deps.defaultSshPublicKeys();
44
+ await Promise.all(keys.map((key) => deps.api.registerSshKey(tokens.access_token, key)));
45
+ deps.progress.succeed("SSH access ready");
46
+ deps.output({ message: "Logged in to Tonbo." });
47
+ }
48
+ catch (error) {
49
+ deps.progress.fail();
50
+ throw error;
51
+ }
52
+ }
53
+ const LOGIN_PROGRESS_MESSAGES = {
54
+ "account-config": {
55
+ started: "Connecting to Tonbo",
56
+ completed: "Connected to Tonbo",
57
+ },
58
+ "callback-server": {
59
+ started: "Starting local browser callback",
60
+ completed: "Local browser callback ready",
61
+ },
62
+ browser: {
63
+ started: "Opening browser",
64
+ completed: "Browser opened",
65
+ },
66
+ "browser-authorization": {
67
+ started: "Waiting for browser authorization",
68
+ completed: "Browser authorization received",
69
+ },
70
+ "callback-close": {
71
+ started: "Closing local browser callback",
72
+ completed: "Local browser callback closed",
73
+ },
74
+ "token-exchange": {
75
+ started: "Exchanging authorization code",
76
+ completed: "Authorization code exchanged",
77
+ },
78
+ "credential-store": {
79
+ started: "Saving login session",
80
+ completed: "Login session saved",
81
+ },
82
+ };
83
+ function reportLoginProgress(progress, event) {
84
+ const messages = LOGIN_PROGRESS_MESSAGES[event.step];
85
+ if (event.status === "started")
86
+ progress.start(messages.started);
87
+ else
88
+ progress.succeed(messages.completed);
50
89
  }
51
90
  export async function sshKeyAddCommand(deps, path) {
52
91
  const key = await readSshPublicKey(path);
@@ -0,0 +1,25 @@
1
+ export interface ProgressReporter {
2
+ start(message: string): void;
3
+ succeed(message?: string): void;
4
+ fail(message?: string): void;
5
+ }
6
+ interface ProgressStream {
7
+ isTTY?: boolean;
8
+ write(value: string): unknown;
9
+ }
10
+ export declare class TerminalProgress implements ProgressReporter {
11
+ private readonly stream;
12
+ private readonly intervalMs;
13
+ private activeMessage;
14
+ private frame;
15
+ private lastWidth;
16
+ private timer;
17
+ constructor(stream: ProgressStream, intervalMs?: number);
18
+ start(message: string): void;
19
+ succeed(message?: string | undefined): void;
20
+ fail(message?: string | undefined): void;
21
+ private finish;
22
+ private render;
23
+ }
24
+ export declare const silentProgress: ProgressReporter;
25
+ export {};
@@ -0,0 +1,60 @@
1
+ const FRAMES = ["|", "/", "-", "\\"];
2
+ export class TerminalProgress {
3
+ stream;
4
+ intervalMs;
5
+ activeMessage;
6
+ frame = 0;
7
+ lastWidth = 0;
8
+ timer;
9
+ constructor(stream, intervalMs = 80) {
10
+ this.stream = stream;
11
+ this.intervalMs = intervalMs;
12
+ }
13
+ start(message) {
14
+ if (this.activeMessage)
15
+ this.succeed();
16
+ this.activeMessage = message;
17
+ this.frame = 0;
18
+ if (!this.stream.isTTY) {
19
+ this.stream.write(`[..] ${message}\n`);
20
+ return;
21
+ }
22
+ this.render(`[${FRAMES[this.frame]}] ${message}`);
23
+ this.timer = setInterval(() => {
24
+ this.frame = (this.frame + 1) % FRAMES.length;
25
+ this.render(`[${FRAMES[this.frame]}] ${this.activeMessage}`);
26
+ }, this.intervalMs);
27
+ this.timer.unref();
28
+ }
29
+ succeed(message = this.activeMessage) {
30
+ this.finish("ok", message);
31
+ }
32
+ fail(message = this.activeMessage) {
33
+ this.finish("!!", message);
34
+ }
35
+ finish(marker, message) {
36
+ if (this.timer)
37
+ clearInterval(this.timer);
38
+ this.timer = undefined;
39
+ this.activeMessage = undefined;
40
+ if (!message)
41
+ return;
42
+ const line = `[${marker}] ${message}`;
43
+ if (this.stream.isTTY) {
44
+ this.render(line);
45
+ this.stream.write("\n");
46
+ this.lastWidth = 0;
47
+ return;
48
+ }
49
+ this.stream.write(`${line}\n`);
50
+ }
51
+ render(value) {
52
+ this.stream.write(`\r${value.padEnd(this.lastWidth)}`);
53
+ this.lastWidth = value.length;
54
+ }
55
+ }
56
+ export const silentProgress = {
57
+ start: () => { },
58
+ succeed: () => { },
59
+ fail: () => { },
60
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tonbo/cli",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "Deploy one persistent Agent per Project from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {