@tonbo/cli 0.0.2 → 0.0.4

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,5 +1,11 @@
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;
@@ -7,7 +13,7 @@ export declare class AuthClient {
7
13
  private readonly browserOpener;
8
14
  constructor(credentials: CredentialStore, fetcher: typeof fetch, accountOrigin?: string, browserOpener?: (url: string) => Promise<void>);
9
15
  accessToken(): Promise<string>;
10
- login(): Promise<OAuthTokenSet>;
16
+ login(progress?: LoginProgress): Promise<OAuthTokenSet>;
11
17
  private config;
12
18
  private exchange;
13
19
  private listenForCode;
package/dist/src/auth.js CHANGED
@@ -55,8 +55,10 @@ export class AuthClient {
55
55
  await this.credentials.save(refreshed);
56
56
  return refreshed.access_token;
57
57
  }
58
- async login() {
58
+ async login(progress = () => { }) {
59
+ progress({ status: "started", step: "account-config" });
59
60
  const config = await this.config();
61
+ progress({ status: "completed", step: "account-config" });
60
62
  if (config.redirect_uri !== LOOPBACK_REDIRECT)
61
63
  throw new Error(`Unsupported OAuth redirect URI: ${config.redirect_uri}`);
62
64
  const verifier = base64url(randomBytes(48));
@@ -72,7 +74,8 @@ export class AuthClient {
72
74
  scope: "openid profile email",
73
75
  state,
74
76
  }).toString();
75
- const code = await this.listenForCode(state, () => this.browserOpener(authorization.toString()));
77
+ const code = await this.listenForCode(state, () => this.browserOpener(authorization.toString()), progress);
78
+ progress({ status: "started", step: "token-exchange" });
76
79
  const tokens = withAbsoluteExpiry(await this.exchange(config.token_endpoint, new URLSearchParams({
77
80
  client_id: config.client_id,
78
81
  code,
@@ -80,7 +83,10 @@ export class AuthClient {
80
83
  grant_type: "authorization_code",
81
84
  redirect_uri: config.redirect_uri,
82
85
  })));
86
+ progress({ status: "completed", step: "token-exchange" });
87
+ progress({ status: "started", step: "credential-store" });
83
88
  await this.credentials.save(tokens);
89
+ progress({ status: "completed", step: "credential-store" });
84
90
  return tokens;
85
91
  }
86
92
  config() {
@@ -93,10 +99,19 @@ export class AuthClient {
93
99
  body,
94
100
  });
95
101
  }
96
- listenForCode(expectedState, ready) {
102
+ listenForCode(expectedState, ready, progress) {
97
103
  return new Promise((resolve, reject) => {
98
104
  let settled = false;
99
- 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) => {
100
115
  if (settled)
101
116
  return;
102
117
  settled = true;
@@ -104,16 +119,24 @@ export class AuthClient {
104
119
  const complete = (closeError) => {
105
120
  if (closeError)
106
121
  reject(closeError);
107
- else if ("code" in result)
122
+ else if ("code" in result) {
123
+ progress({ status: "completed", step: "callback-close" });
108
124
  resolve(result.code);
125
+ }
109
126
  else
110
127
  reject(result.error);
111
128
  };
129
+ if ("code" in result)
130
+ progress({ status: "started", step: "callback-close" });
112
131
  if (!server.listening) {
113
132
  complete();
114
133
  return;
115
134
  }
116
135
  server.close(complete);
136
+ for (const socket of sockets) {
137
+ if (socket !== completedResponseSocket)
138
+ socket.destroy();
139
+ }
117
140
  server.closeIdleConnections();
118
141
  };
119
142
  const timeout = setTimeout(() => {
@@ -124,7 +147,8 @@ export class AuthClient {
124
147
  const code = url.searchParams.get("code");
125
148
  const oauthError = url.searchParams.get("error_description") ?? url.searchParams.get("error");
126
149
  if (oauthError) {
127
- respond(response, "denied", () => finish({ error: new Error(oauthError) }));
150
+ const responseSocket = response.socket;
151
+ respond(response, "denied", () => finish({ error: new Error(oauthError) }, responseSocket));
128
152
  return;
129
153
  }
130
154
  if (url.pathname !== "/callback" ||
@@ -133,12 +157,26 @@ export class AuthClient {
133
157
  respond(response, "invalid");
134
158
  return;
135
159
  }
136
- respond(response, "complete", () => 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));
137
168
  });
138
169
  server.once("error", (error) => finish({ error }));
139
- server.listen(17655, "localhost", () => void ready().catch((error) => finish({
140
- error: error instanceof Error ? error : new Error("Could not open the login browser."),
141
- })));
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
+ });
142
180
  });
143
181
  }
144
182
  }
@@ -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,12 +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({ message: "Logged in to Tonbo." });
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);
46
89
  }
47
90
  export async function sshKeyAddCommand(deps, path) {
48
91
  const key = await readSshPublicKey(path);
@@ -184,4 +184,5 @@ export declare const piSessionContract: {
184
184
  readonly durable_completion_timeout_seconds: 30;
185
185
  readonly session_directory: "/sessions";
186
186
  readonly path_template: "/sessions/{session_id}.jsonl";
187
+ readonly preflight_command: readonly ["/usr/local/bin/artifacts", "runtime", "session-preflight"];
187
188
  };
@@ -217,5 +217,10 @@ export const piSessionContract = {
217
217
  "format_version": 3,
218
218
  "durable_completion_timeout_seconds": 30,
219
219
  "session_directory": "/sessions",
220
- "path_template": "/sessions/{session_id}.jsonl"
220
+ "path_template": "/sessions/{session_id}.jsonl",
221
+ "preflight_command": [
222
+ "/usr/local/bin/artifacts",
223
+ "runtime",
224
+ "session-preflight"
225
+ ]
221
226
  };
@@ -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.2",
3
+ "version": "0.0.4",
4
4
  "description": "Deploy one persistent Agent per Project from the command line.",
5
5
  "homepage": "https://tonbo.dev",
6
6
  "bugs": {