Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306import * as http from "node:http";import { log, note, select, spinner, text } from "@clack/prompts";import { command, flag, option, optional, string } from "cmd-ts";import { resolveHandleToDid } from "../lib/atproto";import { getCallbackPort, getOAuthClient, getOAuthScope,} from "../lib/oauth-client";import { deleteOAuthSession, getOAuthStorePath, listOAuthSessions, listOAuthSessionsWithHandles, setOAuthHandle,} from "../lib/oauth-store";import { exitOnCancel } from "../lib/prompts";
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
export const loginCommand = command({ name: "login", description: "Login with OAuth (browser-based authentication)", args: { logout: option({ long: "logout", description: "Remove OAuth session for a specific DID", type: optional(string), }), list: flag({ long: "list", description: "List all stored OAuth sessions", }), }, handler: async ({ logout, list }) => { // List sessions if (list) { const sessions = await listOAuthSessionsWithHandles(); if (sessions.length === 0) { log.info("No OAuth sessions stored"); } else { log.info("OAuth sessions:"); for (const { did, handle } of sessions) { console.log(` - ${handle || did} (${did})`); } } return; }
// Logout if (logout !== undefined) { const did = logout || undefined;
if (!did) { // No DID provided - show available and prompt const sessions = await listOAuthSessions(); if (sessions.length === 0) { log.info("No OAuth sessions found"); return; } if (sessions.length === 1) { const deleted = await deleteOAuthSession(sessions[0]!); if (deleted) { log.success(`Removed OAuth session for ${sessions[0]}`); } return; } // Multiple sessions - prompt const selected = exitOnCancel( await select({ message: "Select session to remove:", options: sessions.map((d) => ({ value: d, label: d })), }), ); const deleted = await deleteOAuthSession(selected); if (deleted) { log.success(`Removed OAuth session for ${selected}`); } return; }
const deleted = await deleteOAuthSession(did); if (deleted) { log.success(`Removed OAuth session for ${did}`); } else { log.info(`No OAuth session found for ${did}`); } return; }
// OAuth login flow note( "OAuth login will open your browser to authenticate.\n\n" + "This is more secure than app passwords and tokens refresh automatically.", "OAuth Login", );
const handle = exitOnCancel( await text({ message: "Handle or DID:", placeholder: "yourhandle.bsky.social", }), );
if (!handle) { log.error("Handle is required"); process.exit(1); }
const s = spinner(); s.start("Resolving identity...");
let did: string; try { did = await resolveHandleToDid(handle); s.stop(`Identity resolved`); } catch (error) { s.stop("Failed to resolve identity"); if (error instanceof Error) { log.error(`Error: ${error.message}`); } else { log.error(`Error: ${error}`); } process.exit(1); }
s.start("Initializing OAuth...");
try { const client = await getOAuthClient();
// Generate authorization URL using the resolved DID const authUrl = await client.authorize(did, { scope: getOAuthScope(), });
log.info(`Login URL: ${authUrl}`);
s.message("Opening browser...");
// Try to open browser let browserOpened = true; try { const open = (await import("open")).default; await open(authUrl.toString()); } catch { browserOpened = false; }
s.message("Waiting for authentication...");
// Show URL info if (!browserOpened) { s.stop("Could not open browser automatically"); log.warn("Please open the following URL in your browser:"); log.info(authUrl.toString()); s.start("Waiting for authentication..."); }
// Start HTTP server to receive callback const result = await waitForCallback();
if (!result.success) { s.stop("Authentication failed"); log.error(result.error || "OAuth callback failed"); process.exit(1); }
s.message("Completing authentication...");
// Exchange code for tokens const { session } = await client.callback( new URLSearchParams(result.params!), );
// Store the handle for friendly display // Use the original handle input (unless it was a DID) const handleToStore = handle.startsWith("did:") ? undefined : handle; if (handleToStore) { await setOAuthHandle(session.did, handleToStore); }
// Try to get the handle for display (use the original handle input as fallback) const displayName = handleToStore || session.did;
s.stop(`Logged in as ${displayName}`);
log.success(`OAuth session saved to ${getOAuthStorePath()}`); log.info("Your session will refresh automatically when needed.");
// Exit cleanly - the OAuth client may have background processes process.exit(0); } catch (error) { s.stop("OAuth login failed"); if (error instanceof Error) { log.error(`Error: ${error.message}`); } else { log.error(`Error: ${error}`); } process.exit(1); } },});
interface CallbackResult { success: boolean; params?: Record<string, string>; error?: string;}
function waitForCallback(): Promise<CallbackResult> { return new Promise((resolve) => { const port = getCallbackPort(); let timeoutId: ReturnType<typeof setTimeout> | undefined;
const server = http.createServer((req, res) => { const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
if (url.pathname === "/oauth/callback") { const params: Record<string, string> = {}; url.searchParams.forEach((value, key) => { params[key] = value; });
// Clear the timeout if (timeoutId) clearTimeout(timeoutId);
// Check for error if (params.error) { res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <html> <head> <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> </head> <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> <h1 style="font-weight: 400;">Authentication Failed</h1> <p>${params.error_description || params.error}</p> <p>You can close this window.</p> </body> </html> `); server.close(() => { resolve({ success: false, error: params.error_description || params.error, }); }); return; }
// Success res.writeHead(200, { "Content-Type": "text/html" }); res.end(` <html> <head> <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> </head> <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;"> <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> <h1 style="font-weight: 400;">Authentication Successful</h1> <p>You can close this window and return to the terminal.</p> </body> </html> `); server.close(() => { resolve({ success: true, params }); }); return; }
// Not the callback path res.writeHead(404); res.end("Not found"); });
server.on("error", (err: NodeJS.ErrnoException) => { if (timeoutId) clearTimeout(timeoutId); if (err.code === "EADDRINUSE") { resolve({ success: false, error: `Port ${port} is already in use. Please close the application using that port and try again.`, }); } else { resolve({ success: false, error: `Server error: ${err.message}`, }); } });
server.listen(port, "127.0.0.1");
// Timeout after 5 minutes timeoutId = setTimeout(() => { server.close(() => { resolve({ success: false, error: "Timeout waiting for OAuth callback. Please try again.", }); }); }, CALLBACK_TIMEOUT_MS); });}