open source is social v-it.org
0
fork

Configure Feed

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

at main 334 lines 12 kB view raw
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 { AtpAgent } from '@atproto/api'; 8import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; 9import { join } from 'node:path'; 10import { loadConfig, saveConfig } from '../lib/config.js'; 11import { createOAuthClient, createSessionStore, createStore, checkSession } from '../lib/oauth.js'; 12import { configDir, configPath } from '../lib/paths.js'; 13import { vitDir } from '../lib/vit-dir.js'; 14import { errorMessage, formatError } from '../lib/error-format.js'; 15 16export const LOGIN_COMMON_ISSUES_FOOTER = `Common issues: 17 - make sure the handle is correct and resolves on Bluesky 18 - open the printed URL in your browser, approve vit, and wait for the callback to finish 19 - if you're on a remote machine, rerun with 'vit login <handle> --remote' and paste the full callback URL 20 - if you used an app password, create a fresh Bluesky app password and try again 21 - check your DNS, firewall, or VPN settings if vit cannot reach Bluesky`; 22 23function ensureGitignore(dir, entry) { 24 const gitignorePath = join(dir, '.gitignore'); 25 let content = ''; 26 try { content = readFileSync(gitignorePath, 'utf-8'); } catch {} 27 if (!content.split('\n').includes(entry)) { 28 writeFileSync(gitignorePath, content + (content.endsWith('\n') ? '' : '\n') + entry + '\n'); 29 } 30} 31 32export function cancelLogin({ 33 server, 34 rl, 35 timer, 36 clearTimer = clearTimeout, 37 stderr = console.error, 38 exit = process.exit, 39 footer = LOGIN_COMMON_ISSUES_FOOTER, 40}) { 41 try { 42 if (server?.listening) server.close(); 43 } catch { 44 // Ignore cleanup failures during cancellation. 45 } 46 try { 47 rl?.close(); 48 } catch { 49 // Ignore cleanup failures during cancellation. 50 } 51 try { 52 if (timer) clearTimer(timer); 53 } catch { 54 // Ignore cleanup failures during cancellation. 55 } 56 stderr('\nLogin cancelled.'); 57 stderr(footer); 58 exit(130); 59} 60 61export function printLoginFailure(err, { verbose = false, includeFooter = false } = {}) { 62 console.error(formatError(err, { verbose })); 63 if (includeFooter) { 64 console.error(LOGIN_COMMON_ISSUES_FOOTER); 65 } 66} 67 68export default function register(program) { 69 program 70 .command('login') 71 .description('Log in to Bluesky') 72 .argument('<handle>', 'Bluesky handle (e.g. alice.bsky.social)') 73 .option('-v, --verbose', 'Show discovery details') 74 .option('--force', 'Force re-login, skip session validation') 75 .option('--remote', 'Skip browser launch; prompt to paste callback URL (auto-detected over SSH)') 76 .option('--browser <command>', 'Browser command to use (e.g. firefox)') 77 .option('--app-password <password>', 'Authenticate with an app password (skips browser OAuth)') 78 .option('--local', 'Store session in project .vit/ instead of global config') 79 .action(async (handle, opts) => { 80 const { verbose, force, remote, browser, appPassword, local: localLogin } = opts; 81 const isRemote = remote || !!(process.env.SSH_CONNECTION || process.env.SSH_TTY || process.env.SSH_CLIENT); 82 handle = handle.replace(/^@/, ''); 83 84 if (localLogin) { 85 const dir = vitDir(); 86 if (!existsSync(dir)) { 87 console.error("no .vit directory found. run 'vit init' first."); 88 process.exitCode = 1; 89 return; 90 } 91 } 92 93 if (!force) { 94 if (localLogin) { 95 const localPath = join(vitDir(), 'login.json'); 96 if (existsSync(localPath)) { 97 try { 98 const local = JSON.parse(readFileSync(localPath, 'utf-8')); 99 if (local.did) { 100 console.log('Checking local session...'); 101 const validDid = checkSession(local.did); 102 if (validDid) { 103 console.log(`Already logged in locally as ${validDid}`); 104 return; 105 } 106 } 107 } catch (err) { 108 console.warn(`warning: failed to read ${localPath}: ${errorMessage(err)}`); 109 } 110 } 111 } else { 112 const existing = loadConfig(); 113 if (existing.did) { 114 console.log('Checking session...'); 115 const validDid = checkSession(existing.did); 116 if (validDid) { 117 console.log(`Already logged in as ${validDid}`); 118 return; 119 } 120 } 121 } 122 } 123 124 if (appPassword) { 125 try { 126 const agent = new AtpAgent({ service: 'https://bsky.social' }); 127 if (verbose) console.log('[verbose] Authenticating with app password...'); 128 const res = await agent.login({ identifier: handle, password: appPassword }); 129 const { did, handle: resolvedHandle, accessJwt, refreshJwt } = res.data; 130 const session = { accessJwt, refreshJwt, handle: resolvedHandle, did, active: true }; 131 132 if (localLogin) { 133 const loginData = { did, handle: resolvedHandle, type: 'app-password', service: 'https://bsky.social', session }; 134 const dir = vitDir(); 135 writeFileSync(join(dir, 'login.json'), JSON.stringify(loginData, null, 2) + '\n'); 136 ensureGitignore(dir, 'login.json'); 137 } else { 138 const sessionFile = configPath('session.json'); 139 let data = {}; 140 if (existsSync(sessionFile)) { 141 try { 142 data = JSON.parse(readFileSync(sessionFile, 'utf-8')); 143 } catch (err) { 144 console.warn(`warning: failed to read ${sessionFile}: ${errorMessage(err)}`); 145 } 146 } 147 data[did] = { type: 'app-password', service: 'https://bsky.social', session }; 148 mkdirSync(configDir, { recursive: true }); 149 writeFileSync(sessionFile, JSON.stringify(data, null, 2) + '\n'); 150 const config = loadConfig(); 151 config.did = did; 152 saveConfig(config); 153 } 154 155 console.log(`Logged in as ${did}`); 156 } catch (err) { 157 printLoginFailure(err, { verbose: opts.verbose, includeFooter: true }); 158 process.exitCode = 1; 159 } 160 return; 161 } 162 163 let server; 164 let timeout; 165 let rl; 166 let loginStage = 'preflight'; 167 let onSigint = () => {}; 168 169 try { 170 let resolveCallback; 171 let callbackResolved = false; 172 const callbackPromise = new Promise((resolve) => { 173 resolveCallback = resolve; 174 }); 175 176 server = createServer((req, res) => { 177 const url = new URL(req.url, `http://127.0.0.1`); 178 179 if (req.method === 'GET' && url.pathname === '/callback') { 180 const params = new URLSearchParams(url.searchParams); 181 182 if (!callbackResolved) { 183 callbackResolved = true; 184 resolveCallback(params); 185 } 186 187 res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); 188 res.end('<!doctype html><html><body><h2>Authorization complete, you can close this tab.</h2></body></html>'); 189 return; 190 } 191 192 res.writeHead(404); 193 res.end('Not found'); 194 }); 195 196 onSigint = () => { 197 process.off('SIGINT', onSigint); 198 cancelLogin({ server, rl, timer: timeout }); 199 }; 200 process.once('SIGINT', onSigint); 201 202 await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); 203 const port = server.address().port; 204 205 if (verbose) { 206 console.log(`[verbose] Server started on port ${port}`); 207 } 208 209 const redirectUri = `http://127.0.0.1:${port}/callback`; 210 211 if (verbose) { 212 console.log(`[verbose] Redirect URI: ${redirectUri}`); 213 } 214 215 const stateStore = createStore(); 216 const sessionStore = createSessionStore(); 217 const client = createOAuthClient({ stateStore, sessionStore, redirectUri }); 218 219 loginStage = 'authorize'; 220 const authUrl = await client.authorize(handle, { 221 scope: 'atproto transition:generic', 222 }); 223 224 if (verbose) { 225 console.log(`[verbose] Authorization URL: ${authUrl.toString()}`); 226 } 227 228 if (isRemote) { 229 console.log("You're on a remote system. Open this URL in your local browser:"); 230 console.log(` ${authUrl.toString()}\n`); 231 } else { 232 const platform = process.platform; 233 const cmd = browser || (platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open'); 234 const browserArgs = !browser && platform === 'win32' ? ['/c', 'start', authUrl.toString()] : [authUrl.toString()]; 235 236 try { 237 const child = spawn(cmd, browserArgs, { stdio: 'ignore', detached: true }); 238 child.unref(); 239 } catch { 240 // Ignore browser-open failures and rely on printed URL. 241 } 242 243 console.log(`Open this URL in your browser:\n ${authUrl.toString()}\n`); 244 } 245 246 loginStage = 'callback'; 247 if (isRemote) { 248 rl = createInterface({ input: process.stdin, output: process.stdout }); 249 rl.question('Paste the callback URL from your browser: ', (line) => { 250 try { 251 const url = new URL(line.trim()); 252 const params = new URLSearchParams(url.searchParams); 253 if (!callbackResolved) { 254 callbackResolved = true; 255 resolveCallback(params); 256 } 257 } catch { 258 console.error('Invalid URL. Please paste the full callback URL.'); 259 } 260 }); 261 } 262 263 const timeoutMs = 5 * 60 * 1000; 264 const timeoutPromise = new Promise((_, reject) => { 265 timeout = setTimeout(() => { 266 reject(new Error('Timed out waiting for callback.')); 267 }, timeoutMs); 268 }); 269 270 const params = await Promise.race([callbackPromise, timeoutPromise]); 271 272 clearTimeout(timeout); 273 timeout = undefined; 274 server.closeAllConnections?.(); 275 server.close(); 276 if (rl) { 277 rl.close(); 278 } 279 280 if (verbose) { 281 console.log(`[verbose] Callback received with params: ${params.toString()}`); 282 } 283 284 const oauthError = params.get('error'); 285 if (oauthError) { 286 const description = params.get('error_description'); 287 if (description) { 288 throw new Error(`OAuth error: ${oauthError} (${description})`); 289 } 290 throw new Error(`OAuth error: ${oauthError}`); 291 } 292 293 console.log('Exchanging token...'); 294 loginStage = 'token'; 295 const { session } = await client.callback(params); 296 297 if (verbose) { 298 console.log(`[verbose] Token exchange result for DID: ${session.did}`); 299 } 300 301 if (localLogin) { 302 const loginData = { did: session.did, handle, type: 'oauth' }; 303 const dir = vitDir(); 304 writeFileSync(join(dir, 'login.json'), JSON.stringify(loginData, null, 2) + '\n'); 305 ensureGitignore(dir, 'login.json'); 306 } else { 307 const config = loadConfig(); 308 config.did = session.did; 309 saveConfig(config); 310 } 311 console.log(`Logged in as ${session.did}`); 312 } catch (err) { 313 printLoginFailure(err, { 314 verbose: opts.verbose, 315 includeFooter: loginStage !== 'preflight', 316 }); 317 process.exitCode = 1; 318 } finally { 319 process.removeListener('SIGINT', onSigint); 320 if (timeout) { 321 clearTimeout(timeout); 322 } 323 324 if (rl) { 325 rl.close(); 326 } 327 328 if (server?.listening) { 329 server.closeAllConnections?.(); 330 server.close(); 331 } 332 } 333 }); 334}