This repository has no description
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}