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';
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}