A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 305 lines 8.5 kB view raw
1import * as http from "node:http"; 2import { log, note, select, spinner, text } from "@clack/prompts"; 3import { command, flag, option, optional, string } from "cmd-ts"; 4import { resolveHandleToDid } from "../lib/atproto"; 5import { 6 getCallbackPort, 7 getOAuthClient, 8 getOAuthScope, 9} from "../lib/oauth-client"; 10import { 11 deleteOAuthSession, 12 getOAuthStorePath, 13 listOAuthSessions, 14 listOAuthSessionsWithHandles, 15 setOAuthHandle, 16} from "../lib/oauth-store"; 17import { exitOnCancel } from "../lib/prompts"; 18 19const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes 20 21export const loginCommand = command({ 22 name: "login", 23 description: "Login with OAuth (browser-based authentication)", 24 args: { 25 logout: option({ 26 long: "logout", 27 description: "Remove OAuth session for a specific DID", 28 type: optional(string), 29 }), 30 list: flag({ 31 long: "list", 32 description: "List all stored OAuth sessions", 33 }), 34 }, 35 handler: async ({ logout, list }) => { 36 // List sessions 37 if (list) { 38 const sessions = await listOAuthSessionsWithHandles(); 39 if (sessions.length === 0) { 40 log.info("No OAuth sessions stored"); 41 } else { 42 log.info("OAuth sessions:"); 43 for (const { did, handle } of sessions) { 44 console.log(` - ${handle || did} (${did})`); 45 } 46 } 47 return; 48 } 49 50 // Logout 51 if (logout !== undefined) { 52 const did = logout || undefined; 53 54 if (!did) { 55 // No DID provided - show available and prompt 56 const sessions = await listOAuthSessions(); 57 if (sessions.length === 0) { 58 log.info("No OAuth sessions found"); 59 return; 60 } 61 if (sessions.length === 1) { 62 const deleted = await deleteOAuthSession(sessions[0]!); 63 if (deleted) { 64 log.success(`Removed OAuth session for ${sessions[0]}`); 65 } 66 return; 67 } 68 // Multiple sessions - prompt 69 const selected = exitOnCancel( 70 await select({ 71 message: "Select session to remove:", 72 options: sessions.map((d) => ({ value: d, label: d })), 73 }), 74 ); 75 const deleted = await deleteOAuthSession(selected); 76 if (deleted) { 77 log.success(`Removed OAuth session for ${selected}`); 78 } 79 return; 80 } 81 82 const deleted = await deleteOAuthSession(did); 83 if (deleted) { 84 log.success(`Removed OAuth session for ${did}`); 85 } else { 86 log.info(`No OAuth session found for ${did}`); 87 } 88 return; 89 } 90 91 // OAuth login flow 92 note( 93 "OAuth login will open your browser to authenticate.\n\n" + 94 "This is more secure than app passwords and tokens refresh automatically.", 95 "OAuth Login", 96 ); 97 98 const handle = exitOnCancel( 99 await text({ 100 message: "Handle or DID:", 101 placeholder: "yourhandle.bsky.social", 102 }), 103 ); 104 105 if (!handle) { 106 log.error("Handle is required"); 107 process.exit(1); 108 } 109 110 const s = spinner(); 111 s.start("Resolving identity..."); 112 113 let did: string; 114 try { 115 did = await resolveHandleToDid(handle); 116 s.stop(`Identity resolved`); 117 } catch (error) { 118 s.stop("Failed to resolve identity"); 119 if (error instanceof Error) { 120 log.error(`Error: ${error.message}`); 121 } else { 122 log.error(`Error: ${error}`); 123 } 124 process.exit(1); 125 } 126 127 s.start("Initializing OAuth..."); 128 129 try { 130 const client = await getOAuthClient(); 131 132 // Generate authorization URL using the resolved DID 133 const authUrl = await client.authorize(did, { 134 scope: getOAuthScope(), 135 }); 136 137 log.info(`Login URL: ${authUrl}`); 138 139 s.message("Opening browser..."); 140 141 // Try to open browser 142 let browserOpened = true; 143 try { 144 const open = (await import("open")).default; 145 await open(authUrl.toString()); 146 } catch { 147 browserOpened = false; 148 } 149 150 s.message("Waiting for authentication..."); 151 152 // Show URL info 153 if (!browserOpened) { 154 s.stop("Could not open browser automatically"); 155 log.warn("Please open the following URL in your browser:"); 156 log.info(authUrl.toString()); 157 s.start("Waiting for authentication..."); 158 } 159 160 // Start HTTP server to receive callback 161 const result = await waitForCallback(); 162 163 if (!result.success) { 164 s.stop("Authentication failed"); 165 log.error(result.error || "OAuth callback failed"); 166 process.exit(1); 167 } 168 169 s.message("Completing authentication..."); 170 171 // Exchange code for tokens 172 const { session } = await client.callback( 173 new URLSearchParams(result.params!), 174 ); 175 176 // Store the handle for friendly display 177 // Use the original handle input (unless it was a DID) 178 const handleToStore = handle.startsWith("did:") ? undefined : handle; 179 if (handleToStore) { 180 await setOAuthHandle(session.did, handleToStore); 181 } 182 183 // Try to get the handle for display (use the original handle input as fallback) 184 const displayName = handleToStore || session.did; 185 186 s.stop(`Logged in as ${displayName}`); 187 188 log.success(`OAuth session saved to ${getOAuthStorePath()}`); 189 log.info("Your session will refresh automatically when needed."); 190 191 // Exit cleanly - the OAuth client may have background processes 192 process.exit(0); 193 } catch (error) { 194 s.stop("OAuth login failed"); 195 if (error instanceof Error) { 196 log.error(`Error: ${error.message}`); 197 } else { 198 log.error(`Error: ${error}`); 199 } 200 process.exit(1); 201 } 202 }, 203}); 204 205interface CallbackResult { 206 success: boolean; 207 params?: Record<string, string>; 208 error?: string; 209} 210 211function waitForCallback(): Promise<CallbackResult> { 212 return new Promise((resolve) => { 213 const port = getCallbackPort(); 214 let timeoutId: ReturnType<typeof setTimeout> | undefined; 215 216 const server = http.createServer((req, res) => { 217 const url = new URL(req.url || "/", `http://127.0.0.1:${port}`); 218 219 if (url.pathname === "/oauth/callback") { 220 const params: Record<string, string> = {}; 221 url.searchParams.forEach((value, key) => { 222 params[key] = value; 223 }); 224 225 // Clear the timeout 226 if (timeoutId) clearTimeout(timeoutId); 227 228 // Check for error 229 if (params.error) { 230 res.writeHead(200, { "Content-Type": "text/html" }); 231 res.end(` 232 <html> 233 <head> 234 <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 235 </head> 236 <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;"> 237 <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 238 <h1 style="font-weight: 400;">Authentication Failed</h1> 239 <p>${params.error_description || params.error}</p> 240 <p>You can close this window.</p> 241 </body> 242 </html> 243 `); 244 server.close(() => { 245 resolve({ 246 success: false, 247 error: params.error_description || params.error, 248 }); 249 }); 250 return; 251 } 252 253 // Success 254 res.writeHead(200, { "Content-Type": "text/html" }); 255 res.end(` 256 <html> 257 <head> 258 <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet"> 259 </head> 260 <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;"> 261 <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" /> 262 <h1 style="font-weight: 400;">Authentication Successful</h1> 263 <p>You can close this window and return to the terminal.</p> 264 </body> 265 </html> 266 `); 267 server.close(() => { 268 resolve({ success: true, params }); 269 }); 270 return; 271 } 272 273 // Not the callback path 274 res.writeHead(404); 275 res.end("Not found"); 276 }); 277 278 server.on("error", (err: NodeJS.ErrnoException) => { 279 if (timeoutId) clearTimeout(timeoutId); 280 if (err.code === "EADDRINUSE") { 281 resolve({ 282 success: false, 283 error: `Port ${port} is already in use. Please close the application using that port and try again.`, 284 }); 285 } else { 286 resolve({ 287 success: false, 288 error: `Server error: ${err.message}`, 289 }); 290 } 291 }); 292 293 server.listen(port, "127.0.0.1"); 294 295 // Timeout after 5 minutes 296 timeoutId = setTimeout(() => { 297 server.close(() => { 298 resolve({ 299 success: false, 300 error: "Timeout waiting for OAuth callback. Please try again.", 301 }); 302 }); 303 }, CALLBACK_TIMEOUT_MS); 304 }); 305}