A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import * as http from "node:http";
2import { log, note, select, spinner, text } from "@clack/prompts";
3import { command, flag, option, optional, string } from "cmd-ts";
4import { resolveHandleToDid } from "../lib/atproto";
5import {
6 getCallbackPort,
7 getOAuthClient,
8 getOAuthScope,
9} from "../lib/oauth-client";
10import {
11 deleteOAuthSession,
12 getOAuthStorePath,
13 listOAuthSessions,
14 listOAuthSessionsWithHandles,
15 setOAuthHandle,
16} from "../lib/oauth-store";
17import { exitOnCancel } from "../lib/prompts";
18
19const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
20
21export const loginCommand = command({
22 name: "login",
23 description: "Login with OAuth (browser-based authentication)",
24 args: {
25 logout: option({
26 long: "logout",
27 description: "Remove OAuth session for a specific DID",
28 type: optional(string),
29 }),
30 list: flag({
31 long: "list",
32 description: "List all stored OAuth sessions",
33 }),
34 },
35 handler: async ({ logout, list }) => {
36 // List sessions
37 if (list) {
38 const sessions = await listOAuthSessionsWithHandles();
39 if (sessions.length === 0) {
40 log.info("No OAuth sessions stored");
41 } else {
42 log.info("OAuth sessions:");
43 for (const { did, handle } of sessions) {
44 console.log(` - ${handle || did} (${did})`);
45 }
46 }
47 return;
48 }
49
50 // Logout
51 if (logout !== undefined) {
52 const did = logout || undefined;
53
54 if (!did) {
55 // No DID provided - show available and prompt
56 const sessions = await listOAuthSessions();
57 if (sessions.length === 0) {
58 log.info("No OAuth sessions found");
59 return;
60 }
61 if (sessions.length === 1) {
62 const deleted = await deleteOAuthSession(sessions[0]!);
63 if (deleted) {
64 log.success(`Removed OAuth session for ${sessions[0]}`);
65 }
66 return;
67 }
68 // Multiple sessions - prompt
69 const selected = exitOnCancel(
70 await select({
71 message: "Select session to remove:",
72 options: sessions.map((d) => ({ value: d, label: d })),
73 }),
74 );
75 const deleted = await deleteOAuthSession(selected);
76 if (deleted) {
77 log.success(`Removed OAuth session for ${selected}`);
78 }
79 return;
80 }
81
82 const deleted = await deleteOAuthSession(did);
83 if (deleted) {
84 log.success(`Removed OAuth session for ${did}`);
85 } else {
86 log.info(`No OAuth session found for ${did}`);
87 }
88 return;
89 }
90
91 // OAuth login flow
92 note(
93 "OAuth login will open your browser to authenticate.\n\n" +
94 "This is more secure than app passwords and tokens refresh automatically.",
95 "OAuth Login",
96 );
97
98 const handle = exitOnCancel(
99 await text({
100 message: "Handle or DID:",
101 placeholder: "yourhandle.bsky.social",
102 }),
103 );
104
105 if (!handle) {
106 log.error("Handle is required");
107 process.exit(1);
108 }
109
110 const s = spinner();
111 s.start("Resolving identity...");
112
113 let did: string;
114 try {
115 did = await resolveHandleToDid(handle);
116 s.stop(`Identity resolved`);
117 } catch (error) {
118 s.stop("Failed to resolve identity");
119 if (error instanceof Error) {
120 log.error(`Error: ${error.message}`);
121 } else {
122 log.error(`Error: ${error}`);
123 }
124 process.exit(1);
125 }
126
127 s.start("Initializing OAuth...");
128
129 try {
130 const client = await getOAuthClient();
131
132 // Generate authorization URL using the resolved DID
133 const authUrl = await client.authorize(did, {
134 scope: getOAuthScope(),
135 });
136
137 log.info(`Login URL: ${authUrl}`);
138
139 s.message("Opening browser...");
140
141 // Try to open browser
142 let browserOpened = true;
143 try {
144 const open = (await import("open")).default;
145 await open(authUrl.toString());
146 } catch {
147 browserOpened = false;
148 }
149
150 s.message("Waiting for authentication...");
151
152 // Show URL info
153 if (!browserOpened) {
154 s.stop("Could not open browser automatically");
155 log.warn("Please open the following URL in your browser:");
156 log.info(authUrl.toString());
157 s.start("Waiting for authentication...");
158 }
159
160 // Start HTTP server to receive callback
161 const result = await waitForCallback();
162
163 if (!result.success) {
164 s.stop("Authentication failed");
165 log.error(result.error || "OAuth callback failed");
166 process.exit(1);
167 }
168
169 s.message("Completing authentication...");
170
171 // Exchange code for tokens
172 const { session } = await client.callback(
173 new URLSearchParams(result.params!),
174 );
175
176 // Store the handle for friendly display
177 // Use the original handle input (unless it was a DID)
178 const handleToStore = handle.startsWith("did:") ? undefined : handle;
179 if (handleToStore) {
180 await setOAuthHandle(session.did, handleToStore);
181 }
182
183 // Try to get the handle for display (use the original handle input as fallback)
184 const displayName = handleToStore || session.did;
185
186 s.stop(`Logged in as ${displayName}`);
187
188 log.success(`OAuth session saved to ${getOAuthStorePath()}`);
189 log.info("Your session will refresh automatically when needed.");
190
191 // Exit cleanly - the OAuth client may have background processes
192 process.exit(0);
193 } catch (error) {
194 s.stop("OAuth login failed");
195 if (error instanceof Error) {
196 log.error(`Error: ${error.message}`);
197 } else {
198 log.error(`Error: ${error}`);
199 }
200 process.exit(1);
201 }
202 },
203});
204
205interface CallbackResult {
206 success: boolean;
207 params?: Record<string, string>;
208 error?: string;
209}
210
211function waitForCallback(): Promise<CallbackResult> {
212 return new Promise((resolve) => {
213 const port = getCallbackPort();
214 let timeoutId: ReturnType<typeof setTimeout> | undefined;
215
216 const server = http.createServer((req, res) => {
217 const url = new URL(req.url || "/", `http://127.0.0.1:${port}`);
218
219 if (url.pathname === "/oauth/callback") {
220 const params: Record<string, string> = {};
221 url.searchParams.forEach((value, key) => {
222 params[key] = value;
223 });
224
225 // Clear the timeout
226 if (timeoutId) clearTimeout(timeoutId);
227
228 // Check for error
229 if (params.error) {
230 res.writeHead(200, { "Content-Type": "text/html" });
231 res.end(`
232 <html>
233 <head>
234 <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet">
235 </head>
236 <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
237 <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" />
238 <h1 style="font-weight: 400;">Authentication Failed</h1>
239 <p>${params.error_description || params.error}</p>
240 <p>You can close this window.</p>
241 </body>
242 </html>
243 `);
244 server.close(() => {
245 resolve({
246 success: false,
247 error: params.error_description || params.error,
248 });
249 });
250 return;
251 }
252
253 // Success
254 res.writeHead(200, { "Content-Type": "text/html" });
255 res.end(`
256 <html>
257 <head>
258 <link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap" rel="stylesheet">
259 </head>
260 <body style="background: #1A1A1A; color: #F5F3EF; font-family: 'Josefin Sans', system-ui; padding: 2rem; text-align: center; display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 100vh; margin: 0;">
261 <img src="https://sequoia.pub/icon-dark.png" alt="sequoia icon" style="width: 100px; height: 100px;" />
262 <h1 style="font-weight: 400;">Authentication Successful</h1>
263 <p>You can close this window and return to the terminal.</p>
264 </body>
265 </html>
266 `);
267 server.close(() => {
268 resolve({ success: true, params });
269 });
270 return;
271 }
272
273 // Not the callback path
274 res.writeHead(404);
275 res.end("Not found");
276 });
277
278 server.on("error", (err: NodeJS.ErrnoException) => {
279 if (timeoutId) clearTimeout(timeoutId);
280 if (err.code === "EADDRINUSE") {
281 resolve({
282 success: false,
283 error: `Port ${port} is already in use. Please close the application using that port and try again.`,
284 });
285 } else {
286 resolve({
287 success: false,
288 error: `Server error: ${err.message}`,
289 });
290 }
291 });
292
293 server.listen(port, "127.0.0.1");
294
295 // Timeout after 5 minutes
296 timeoutId = setTimeout(() => {
297 server.close(() => {
298 resolve({
299 success: false,
300 error: "Timeout waiting for OAuth callback. Please try again.",
301 });
302 });
303 }, CALLBACK_TIMEOUT_MS);
304 });
305}