A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
58
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}