import { createServer } from 'node:http'; import { readFile, writeFile } from 'node:fs/promises'; import type { AddressInfo } from 'node:net'; import { createInterface } from 'node:readline/promises'; import type { NodeSavedSession, NodeSavedSessionStore, NodeSavedState, NodeSavedStateStore } from '@atproto/oauth-client-node'; async function readJsonFile(path: string): Promise { try { const raw = await readFile(path, 'utf-8'); return JSON.parse(raw) as T; } catch { return {} as T; } } async function writeJsonFile(path: string, data: unknown, mode?: number): Promise { await writeFile(path, JSON.stringify(data, null, 2), { mode }); } export function createFileStateStore(filePath: string): NodeSavedStateStore { return { async set(key: string, value: NodeSavedState) { const data = await readJsonFile>(filePath); data[key] = value; await writeJsonFile(filePath, data, 0o600); }, async get(key: string): Promise { const data = await readJsonFile>(filePath); return data[key]; }, async del(key: string) { const data = await readJsonFile>(filePath); delete data[key]; await writeJsonFile(filePath, data, 0o600); }, }; } export function createFileSessionStore(filePath: string): NodeSavedSessionStore { return { async set(sub: string, value: NodeSavedSession) { const data = await readJsonFile>(filePath); data[sub] = value; await writeJsonFile(filePath, data, 0o600); }, async get(sub: string): Promise { const data = await readJsonFile>(filePath); return data[sub]; }, async del(sub: string) { const data = await readJsonFile>(filePath); delete data[sub]; await writeJsonFile(filePath, data, 0o600); }, }; } export function startLoopbackCallbackServer(port = 0): Promise<{ port: number; getParams: () => Promise }> { return new Promise((resolve, reject) => { let gotParams: ((params: URLSearchParams) => void) | undefined; let rejectParams: ((err: Error) => void) | undefined; const paramsPromise = new Promise((res, rej) => { gotParams = res; rejectParams = rej; }); const server = createServer((req, res) => { const reqUrl = new URL(req.url || '/', 'http://127.0.0.1'); if (reqUrl.pathname !== '/callback') { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('not found'); return; } const params = reqUrl.searchParams; res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('

Authentication successful. You can close this tab and return to the terminal.

'); server.close((closeErr) => { if (closeErr) { rejectParams?.(closeErr); } else { gotParams?.(params); } }); }); server.on('error', reject); server.listen({ host: '127.0.0.1', port }, () => { const address = server.address() as AddressInfo; resolve({ port: address.port, getParams: () => paramsPromise, }); }); }); } export async function promptForHandle(): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question('Enter your atproto handle (e.g. handle.example.com): '); return answer.trim(); } finally { rl.close(); } } export async function confirmOAuth(): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout }); try { const answer = await rl.question('No app password found in .env or --atproto-app-password. Would you like to authenticate via OAuth [Y/n]? '); const normalized = answer.trim().toLowerCase(); return normalized === '' || normalized === 'y' || normalized === 'yes'; } finally { rl.close(); } }