Skip to content

Commit dd297a5

Browse files
recuu-pfegclaude
andcommitted
feat: toban init browser auth — device flow with GitHub OAuth
Select auth method: browser login or manual API key paste. Browser flow: POST /auth/cli/start → auto-open browser → poll every 2s. Auto-opens with open/xdg-open/start. 5-min timeout. Manual fallback preserved. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8137369 commit dd297a5

1 file changed

Lines changed: 127 additions & 9 deletions

File tree

src/commands/init.ts

Lines changed: 127 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import * as p from "@clack/prompts";
1212
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1313
import { join } from "node:path";
14+
import { execSync } from "node:child_process";
1415
import { createApiClient, type WorkspaceInfo } from "../api-client.js";
1516

1617
// ---------------------------------------------------------------------------
@@ -59,6 +60,68 @@ async function validateApiKey(apiUrl: string, apiKey: string): Promise<Workspace
5960
}
6061
}
6162

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+
62125
// ---------------------------------------------------------------------------
63126
// Main
64127
// ---------------------------------------------------------------------------
@@ -93,16 +156,71 @@ export async function handleInit(): Promise<void> {
93156
});
94157
if (isCancel(apiUrl)) { p.outro("Cancelled."); return; }
95158

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+
],
104166
});
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+
}
106224

107225
// 3. Validate key
108226
const spin = p.spinner();

0 commit comments

Comments
 (0)