open source is social v-it.org
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}