|
11 | 11 | import * as p from "@clack/prompts"; |
12 | 12 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
13 | 13 | import { join } from "node:path"; |
| 14 | +import { execSync } from "node:child_process"; |
14 | 15 | import { createApiClient, type WorkspaceInfo } from "../api-client.js"; |
15 | 16 |
|
16 | 17 | // --------------------------------------------------------------------------- |
@@ -59,6 +60,68 @@ async function validateApiKey(apiUrl: string, apiKey: string): Promise<Workspace |
59 | 60 | } |
60 | 61 | } |
61 | 62 |
|
| 63 | +/** Open a URL in the default browser. */ |
| 64 | +function openBrowser(url: string): void { |
| 65 | + try { |
| 66 | + const platform = process.platform; |
| 67 | + if (platform === "darwin") { |
| 68 | + execSync(`open ${JSON.stringify(url)}`, { stdio: "ignore" }); |
| 69 | + } else if (platform === "win32") { |
| 70 | + execSync(`start "" ${JSON.stringify(url)}`, { stdio: "ignore" }); |
| 71 | + } else { |
| 72 | + execSync(`xdg-open ${JSON.stringify(url)}`, { stdio: "ignore" }); |
| 73 | + } |
| 74 | + } catch { |
| 75 | + // Best effort — user can manually open the URL |
| 76 | + } |
| 77 | +} |
| 78 | + |
| 79 | +interface CliAuthStartResponse { |
| 80 | + cli_code: string; |
| 81 | + poll_token: string; |
| 82 | + auth_url: string; |
| 83 | +} |
| 84 | + |
| 85 | +interface CliAuthPollResponse { |
| 86 | + status: "pending" | "completed" | "expired"; |
| 87 | + api_key?: string; |
| 88 | + workspace_id?: string; |
| 89 | + workspace_name?: string; |
| 90 | +} |
| 91 | + |
| 92 | +/** Start a CLI auth session on the API. */ |
| 93 | +async function startCliAuth(apiUrl: string): Promise<CliAuthStartResponse | null> { |
| 94 | + try { |
| 95 | + const res = await fetch(`${apiUrl}/api/auth/cli/start`, { method: "POST" }); |
| 96 | + if (!res.ok) return null; |
| 97 | + return (await res.json()) as CliAuthStartResponse; |
| 98 | + } catch { |
| 99 | + return null; |
| 100 | + } |
| 101 | +} |
| 102 | + |
| 103 | +/** Poll for CLI auth completion. */ |
| 104 | +async function pollCliAuth(apiUrl: string, pollToken: string): Promise<CliAuthPollResponse> { |
| 105 | + const res = await fetch(`${apiUrl}/api/auth/cli/poll?poll_token=${encodeURIComponent(pollToken)}`); |
| 106 | + if (!res.ok) throw new Error("Poll request failed"); |
| 107 | + return (await res.json()) as CliAuthPollResponse; |
| 108 | +} |
| 109 | + |
| 110 | +/** Wait for auth completion by polling every 2s, up to timeoutMs. */ |
| 111 | +async function waitForCliAuth( |
| 112 | + apiUrl: string, |
| 113 | + pollToken: string, |
| 114 | + timeoutMs: number, |
| 115 | +): Promise<CliAuthPollResponse> { |
| 116 | + const deadline = Date.now() + timeoutMs; |
| 117 | + while (Date.now() < deadline) { |
| 118 | + const result = await pollCliAuth(apiUrl, pollToken); |
| 119 | + if (result.status !== "pending") return result; |
| 120 | + await new Promise((resolve) => setTimeout(resolve, 2000)); |
| 121 | + } |
| 122 | + return { status: "expired" }; |
| 123 | +} |
| 124 | + |
62 | 125 | // --------------------------------------------------------------------------- |
63 | 126 | // Main |
64 | 127 | // --------------------------------------------------------------------------- |
@@ -93,16 +156,71 @@ export async function handleInit(): Promise<void> { |
93 | 156 | }); |
94 | 157 | if (isCancel(apiUrl)) { p.outro("Cancelled."); return; } |
95 | 158 |
|
96 | | - // 2. API Key |
97 | | - const apiKey = await p.text({ |
98 | | - message: "API key", |
99 | | - placeholder: "tb_xxx", |
100 | | - validate: (v) => { |
101 | | - if (!v) return "API key is required"; |
102 | | - if (!v.startsWith("tb_")) return "API key must start with tb_"; |
103 | | - }, |
| 159 | + // 2. Authentication — browser login or manual API key |
| 160 | + const authMethod = await p.select({ |
| 161 | + message: "How would you like to authenticate?", |
| 162 | + options: [ |
| 163 | + { value: "browser", label: "Login with GitHub (opens browser)" }, |
| 164 | + { value: "manual", label: "Paste API key manually" }, |
| 165 | + ], |
104 | 166 | }); |
105 | | - if (isCancel(apiKey)) { p.outro("Cancelled."); return; } |
| 167 | + if (isCancel(authMethod)) { p.outro("Cancelled."); return; } |
| 168 | + |
| 169 | + let apiKey: string; |
| 170 | + let workspaceId: string | undefined; |
| 171 | + let workspaceName: string | undefined; |
| 172 | + |
| 173 | + if (authMethod === "browser") { |
| 174 | + // Browser-based auth flow |
| 175 | + const spin = p.spinner(); |
| 176 | + spin.start("Connecting to API..."); |
| 177 | + |
| 178 | + const authSession = await startCliAuth(apiUrl); |
| 179 | + if (!authSession) { |
| 180 | + spin.stop("Connection failed"); |
| 181 | + p.log.error("Could not connect to the API. Check your API URL."); |
| 182 | + p.outro("Setup failed."); |
| 183 | + process.exit(1); |
| 184 | + } |
| 185 | + spin.stop("Connected"); |
| 186 | + |
| 187 | + p.note( |
| 188 | + [ |
| 189 | + `URL: ${authSession.auth_url}`, |
| 190 | + `Code: ${authSession.cli_code}`, |
| 191 | + ].join("\n"), |
| 192 | + "Open in browser to authenticate" |
| 193 | + ); |
| 194 | + |
| 195 | + openBrowser(authSession.auth_url); |
| 196 | + |
| 197 | + spin.start("Waiting for authentication (timeout: 5 min)..."); |
| 198 | + const result = await waitForCliAuth(apiUrl, authSession.poll_token, 5 * 60 * 1000); |
| 199 | + |
| 200 | + if (result.status === "completed" && result.api_key) { |
| 201 | + spin.stop("Authenticated"); |
| 202 | + apiKey = result.api_key; |
| 203 | + workspaceId = result.workspace_id; |
| 204 | + workspaceName = result.workspace_name; |
| 205 | + } else { |
| 206 | + spin.stop("Authentication timed out or was rejected"); |
| 207 | + p.log.error("Browser authentication did not complete in time."); |
| 208 | + p.outro("Setup failed."); |
| 209 | + process.exit(1); |
| 210 | + } |
| 211 | + } else { |
| 212 | + // Manual API key |
| 213 | + const manualKey = await p.text({ |
| 214 | + message: "API key", |
| 215 | + placeholder: "tb_xxx", |
| 216 | + validate: (v) => { |
| 217 | + if (!v) return "API key is required"; |
| 218 | + if (!v.startsWith("tb_")) return "API key must start with tb_"; |
| 219 | + }, |
| 220 | + }); |
| 221 | + if (isCancel(manualKey)) { p.outro("Cancelled."); return; } |
| 222 | + apiKey = manualKey; |
| 223 | + } |
106 | 224 |
|
107 | 225 | // 3. Validate key |
108 | 226 | const spin = p.spinner(); |
|
0 commit comments