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