This repository has no description
0

Configure Feed

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

vit / src / cmd / login.js
6.3 kB 185 lines
1// SPDX-License-Identifier: MIT 2// Copyright (c) 2026 sol pbc 3 4import { createServer } from 'node:http'; 5import { spawn } from 'node:child_process'; 6import { createInterface } from 'node:readline'; 7import { loadConfig, saveConfig } from '../lib/config.js'; 8import { createOAuthClient, createSessionStore, createStore, checkSession } from '../lib/oauth.js'; 9 10export default function register(program) { 11 program 12 .command('login') 13 .description('Log in to Bluesky via browser-based OAuth') 14 .argument('<handle>', 'Bluesky handle (e.g. alice.bsky.social)') 15 .option('-v, --verbose', 'Show discovery details') 16 .option('--force', 'Force re-login, skip session validation') 17 .option('--remote', 'Skip browser launch; prompt to paste callback URL (auto-detected over SSH)') 18 .option('--browser <command>', 'Browser command to use (e.g. firefox)') 19 .action(async (handle, opts) => { 20 const { verbose, force, remote, browser } = opts; 21 const isRemote = remote || !!(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT); 22 handle = handle.replace(/^@/, ''); 23 24 if (!force) { 25 const existing = loadConfig(); 26 if (existing.did) { 27 console.log('Checking session...'); 28 const validDid = checkSession(existing.did); 29 if (validDid) { 30 console.log(`Already logged in as ${validDid}`); 31 return; 32 } 33 } 34 } 35 36 let server; 37 let timeout; 38 let rl; 39 40 try { 41 let resolveCallback; 42 let callbackResolved = false; 43 const callbackPromise = new Promise((resolve) => { 44 resolveCallback = resolve; 45 }); 46 47 server = createServer((req, res) => { 48 const url = new URL(req.url, `http://127.0.0.1`); 49 50 if (req.method === 'GET' && url.pathname === '/callback') { 51 const params = new URLSearchParams(url.searchParams); 52 53 if (!callbackResolved) { 54 callbackResolved = true; 55 resolveCallback(params); 56 } 57 58 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); 59 res.end('<!doctype html><html><body><h2>Authorization complete, you can close this tab.</h2></body></html>'); 60 return; 61 } 62 63 res.writeHead(404); 64 res.end('Not found'); 65 }); 66 67 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); 68 const port = server.address().port; 69 70 if (verbose) { 71 console.log(`[verbose] Server started on port ${port}`); 72 } 73 74 const redirectUri = `http://127.0.0.1:${port}/callback`; 75 76 if (verbose) { 77 console.log(`[verbose] Redirect URI: ${redirectUri}`); 78 } 79 80 const stateStore = createStore(); 81 const sessionStore = createSessionStore(); 82 const client = createOAuthClient({ stateStore, sessionStore, redirectUri }); 83 84 const authUrl = await client.authorize(handle, { 85 scope: 'atproto transition:generic', 86 }); 87 88 if (verbose) { 89 console.log(`[verbose] Authorization URL: ${authUrl.toString()}`); 90 } 91 92 if (isRemote) { 93 console.log("You're on a remote system. Open this URL in your local browser:"); 94 console.log(` ${authUrl.toString()}\n`); 95 } else { 96 const platform = process.platform; 97 const cmd = browser || (platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'); 98 const browserArgs = !browser && platform === 'win32' ? ['/c', 'start', authUrl.toString()] : [authUrl.toString()]; 99 100 try { 101 const child = spawn(cmd, browserArgs, { stdio: 'ignore', detached: true }); 102 child.unref(); 103 } catch { 104 // Ignore browser-open failures and rely on printed URL. 105 } 106 107 console.log(`Open this URL in your browser:\n ${authUrl.toString()}\n`); 108 } 109 110 if (isRemote) { 111 rl = createInterface({ input: process.stdin, output: process.stdout }); 112 rl.question('Paste the callback URL from your browser: ', (line) => { 113 try { 114 const url = new URL(line.trim()); 115 const params = new URLSearchParams(url.searchParams); 116 if (!callbackResolved) { 117 callbackResolved = true; 118 resolveCallback(params); 119 } 120 } catch { 121 console.error('Invalid URL. Please paste the full callback URL.'); 122 } 123 }); 124 } 125 126 const timeoutMs = 5 * 60 * 1000; 127 const timeoutPromise = new Promise((_, reject) => { 128 timeout = setTimeout(() => { 129 reject(new Error('Timed out waiting for callback.')); 130 }, timeoutMs); 131 }); 132 133 const params = await Promise.race([callbackPromise, timeoutPromise]); 134 135 clearTimeout(timeout); 136 timeout = undefined; 137 server.closeAllConnections?.(); 138 server.close(); 139 if (rl) { 140 rl.close(); 141 } 142 143 if (verbose) { 144 console.log(`[verbose] Callback received with params: ${params.toString()}`); 145 } 146 147 const oauthError = params.get('error'); 148 if (oauthError) { 149 const description = params.get('error_description'); 150 if (description) { 151 throw new Error(`OAuth error: ${oauthError} (${description})`); 152 } 153 throw new Error(`OAuth error: ${oauthError}`); 154 } 155 156 console.log('Exchanging token...'); 157 const { session } = await client.callback(params); 158 159 if (verbose) { 160 console.log(`[verbose] Token exchange result for DID: ${session.did}`); 161 } 162 163 const config = loadConfig(); 164 config.did = session.did; 165 saveConfig(config); 166 console.log(`Logged in as ${session.did}`); 167 } catch (err) { 168 console.error(err instanceof Error ? err.message : String(err)); 169 process.exitCode = 1; 170 } finally { 171 if (timeout) { 172 clearTimeout(timeout); 173 } 174 175 if (rl) { 176 rl.close(); 177 } 178 179 if (server?.listening) { 180 server.closeAllConnections?.(); 181 server.close(); 182 } 183 } 184 }); 185}