import { spawn } from "node:child_process"; /** * Key validation for `taleseal login --key`: one POST to /v1/tales with an empty JSON * body. 401 means the key is bad (refuse to store it). 400 or 422 means the key * authenticated and only the body was rejected — exactly what an empty body should earn * (the live server answers 422 for a JSON body that fails tale validation, 400/413 for * bodies it refuses earlier). Anything else is inconclusive (the server may be * unreachable or misbehaving); the caller stores the key anyway, with a warning. */ export type KeyValidation = { verdict: "valid" } | { verdict: "invalid" } | { verdict: "unknown"; detail: string }; export async function validateKey( baseUrl: string, apiKey: string, fetchFn: typeof fetch = fetch, ): Promise { try { const res = await fetchFn(`${baseUrl.replace(/\/$/, "")}/v1/tales`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` }, body: "{}", }); if (res.status === 401) return { verdict: "invalid" }; if (res.status === 400 || res.status === 422) return { verdict: "valid" }; return { verdict: "unknown", detail: `the server answered ${res.status}` }; } catch (error) { return { verdict: "unknown", detail: error instanceof Error ? error.message : String(error) }; } } /** * The browser handshake behind plain `taleseal login` (no --key): start a device login, * send the human to the approve page, poll until the key arrives. Device-flow shaped — * chosen over a localhost callback because agent runs live on SSH boxes and in containers, * where "approve on your laptop, key lands on the server" is the whole point. */ export interface DeviceStart { /** the poll secret — never displayed, never logged */ device: string; /** the short human-match code, shown in the terminal AND on the approve page */ code: string; /** the approve page URL to open */ url: string; /** poll cadence in seconds */ interval: number; /** handshake lifetime in seconds */ expiresIn: number; } export async function startDeviceLogin( baseUrl: string, name: string, fetchFn: typeof fetch = fetch, ): Promise { const res = await fetchFn(`${baseUrl.replace(/\/$/, "")}/v1/cli/start`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name }), }); if (!res.ok) throw new Error(`could not start a login with ${baseUrl}: the server answered ${res.status}`); const body = (await res.json()) as Partial; if ( typeof body.device !== "string" || typeof body.code !== "string" || typeof body.url !== "string" || typeof body.interval !== "number" || typeof body.expiresIn !== "number" ) { throw new Error(`could not start a login with ${baseUrl}: unexpected response shape`); } return body as DeviceStart; } export type DevicePollResult = { status: "approved"; key: string; email?: string } | { status: "expired" }; /** * Polls until the handshake resolves. Network blips and 429s are ridden out (the server * deadline is the real clock); only an explicit "expired" or the local deadline ends it. */ export async function pollDeviceLogin( baseUrl: string, start: DeviceStart, fetchFn: typeof fetch = fetch, sleep: (ms: number) => Promise = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), now: () => number = Date.now, ): Promise { const deadline = now() + start.expiresIn * 1000; while (now() < deadline) { await sleep(start.interval * 1000); let body: unknown; try { const res = await fetchFn(`${baseUrl.replace(/\/$/, "")}/v1/cli/poll`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ device: start.device }), }); body = await res.json(); } catch { continue; } if (typeof body !== "object" || body === null) continue; const record = body as Record; if (record.status === "expired") return { status: "expired" }; if (record.status === "approved" && typeof record.key === "string") { return { status: "approved", key: record.key, email: typeof record.email === "string" ? record.email : undefined, }; } } return { status: "expired" }; } /** Best-effort browser launch; failure is fine — the URL is printed either way. */ export function openBrowser(url: string): void { const [cmd, args]: [string, string[]] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]]; try { const child = spawn(cmd, args, { stdio: "ignore", detached: true }); child.on("error", () => {}); child.unref(); } catch { // headless box, no opener — the printed URL covers it } }