A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.3.1 286 lines 7.4 kB view raw
1import * as fs from "node:fs/promises"; 2import * as os from "node:os"; 3import * as path from "node:path"; 4import { 5 getOAuthHandle, 6 getOAuthSession, 7 listOAuthSessions, 8 listOAuthSessionsWithHandles, 9} from "./oauth-store"; 10import type { 11 AppPasswordCredentials, 12 Credentials, 13 LegacyCredentials, 14 OAuthCredentials, 15} from "./types"; 16 17const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia"); 18const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json"); 19 20// Stored credentials keyed by identifier (can be legacy or typed) 21type CredentialsStore = Record< 22 string, 23 AppPasswordCredentials | LegacyCredentials 24>; 25 26async function fileExists(filePath: string): Promise<boolean> { 27 try { 28 await fs.access(filePath); 29 return true; 30 } catch { 31 return false; 32 } 33} 34 35/** 36 * Normalize credentials to have explicit type 37 */ 38function normalizeCredentials( 39 creds: AppPasswordCredentials | LegacyCredentials, 40): AppPasswordCredentials { 41 // If it already has type, return as-is 42 if ("type" in creds && creds.type === "app-password") { 43 return creds; 44 } 45 // Migrate legacy format 46 return { 47 type: "app-password", 48 pdsUrl: creds.pdsUrl, 49 identifier: creds.identifier, 50 password: creds.password, 51 }; 52} 53 54async function loadCredentialsStore(): Promise<CredentialsStore> { 55 if (!(await fileExists(CREDENTIALS_FILE))) { 56 return {}; 57 } 58 59 try { 60 const content = await fs.readFile(CREDENTIALS_FILE, "utf-8"); 61 const parsed = JSON.parse(content); 62 63 // Handle legacy single-credential format (migrate on read) 64 if (parsed.identifier && parsed.password) { 65 const legacy = parsed as LegacyCredentials; 66 return { [legacy.identifier]: legacy }; 67 } 68 69 return parsed as CredentialsStore; 70 } catch { 71 return {}; 72 } 73} 74 75/** 76 * Save the entire credentials store 77 */ 78async function saveCredentialsStore(store: CredentialsStore): Promise<void> { 79 await fs.mkdir(CONFIG_DIR, { recursive: true }); 80 await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2)); 81 await fs.chmod(CREDENTIALS_FILE, 0o600); 82} 83 84/** 85 * Try to load OAuth credentials for a given profile (DID or handle) 86 */ 87async function tryLoadOAuthCredentials( 88 profile: string, 89): Promise<OAuthCredentials | null> { 90 // If it looks like a DID, try to get the session directly 91 if (profile.startsWith("did:")) { 92 const session = await getOAuthSession(profile); 93 if (session) { 94 const handle = await getOAuthHandle(profile); 95 return { 96 type: "oauth", 97 did: profile, 98 handle: handle || profile, 99 pdsUrl: "https://bsky.social", // Will be resolved from DID doc 100 }; 101 } 102 } 103 104 // Try to find OAuth session by handle 105 const sessions = await listOAuthSessionsWithHandles(); 106 const match = sessions.find((s) => s.handle === profile); 107 if (match) { 108 return { 109 type: "oauth", 110 did: match.did, 111 handle: match.handle || match.did, 112 pdsUrl: "https://bsky.social", 113 }; 114 } 115 116 return null; 117} 118 119/** 120 * Load credentials for a specific identity or resolve which to use. 121 * 122 * Priority: 123 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 124 * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 125 * 3. projectIdentity parameter (from sequoia.json) 126 * 4. If only one identity stored (app-password or OAuth), use it 127 * 5. Return null (caller should prompt user) 128 */ 129export async function loadCredentials( 130 projectIdentity?: string, 131): Promise<Credentials | null> { 132 // 1. Check environment variables first (full override) 133 const envIdentifier = process.env.ATP_IDENTIFIER; 134 const envPassword = process.env.ATP_APP_PASSWORD; 135 const envPdsUrl = process.env.PDS_URL; 136 137 if (envIdentifier && envPassword) { 138 return { 139 type: "app-password", 140 identifier: envIdentifier, 141 password: envPassword, 142 pdsUrl: envPdsUrl || "https://bsky.social", 143 }; 144 } 145 146 const store = await loadCredentialsStore(); 147 const appPasswordIds = Object.keys(store); 148 const oauthDids = await listOAuthSessions(); 149 150 // 2. SEQUOIA_PROFILE env var 151 const profileEnv = process.env.SEQUOIA_PROFILE; 152 if (profileEnv) { 153 // Try app-password credentials first 154 if (store[profileEnv]) { 155 return normalizeCredentials(store[profileEnv]); 156 } 157 // Try OAuth session (profile could be a DID) 158 const oauth = await tryLoadOAuthCredentials(profileEnv); 159 if (oauth) { 160 return oauth; 161 } 162 } 163 164 // 3. Project-specific identity (from sequoia.json) 165 if (projectIdentity) { 166 if (store[projectIdentity]) { 167 return normalizeCredentials(store[projectIdentity]); 168 } 169 const oauth = await tryLoadOAuthCredentials(projectIdentity); 170 if (oauth) { 171 return oauth; 172 } 173 } 174 175 // 4. If only one identity total, use it 176 const totalIdentities = appPasswordIds.length + oauthDids.length; 177 if (totalIdentities === 1) { 178 if (appPasswordIds.length === 1 && appPasswordIds[0]) { 179 return normalizeCredentials(store[appPasswordIds[0]]!); 180 } 181 if (oauthDids.length === 1 && oauthDids[0]) { 182 const session = await getOAuthSession(oauthDids[0]); 183 if (session) { 184 const handle = await getOAuthHandle(oauthDids[0]); 185 return { 186 type: "oauth", 187 did: oauthDids[0], 188 handle: handle || oauthDids[0], 189 pdsUrl: "https://bsky.social", 190 }; 191 } 192 } 193 } 194 195 // Multiple identities exist but none selected, or no identities 196 return null; 197} 198 199/** 200 * Get a specific identity by identifier (app-password only) 201 */ 202export async function getCredentials( 203 identifier: string, 204): Promise<AppPasswordCredentials | null> { 205 const store = await loadCredentialsStore(); 206 const creds = store[identifier]; 207 if (!creds) return null; 208 return normalizeCredentials(creds); 209} 210 211/** 212 * List all stored app-password identities 213 */ 214export async function listCredentials(): Promise<string[]> { 215 const store = await loadCredentialsStore(); 216 return Object.keys(store); 217} 218 219/** 220 * List all credentials (both app-password and OAuth) 221 */ 222export async function listAllCredentials(): Promise< 223 Array<{ id: string; type: "app-password" | "oauth" }> 224> { 225 const store = await loadCredentialsStore(); 226 const oauthDids = await listOAuthSessions(); 227 228 const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 229 230 for (const id of Object.keys(store)) { 231 result.push({ id, type: "app-password" }); 232 } 233 234 for (const did of oauthDids) { 235 result.push({ id: did, type: "oauth" }); 236 } 237 238 return result; 239} 240 241/** 242 * Save app-password credentials for an identity (adds or updates) 243 */ 244export async function saveCredentials( 245 credentials: AppPasswordCredentials, 246): Promise<void> { 247 const store = await loadCredentialsStore(); 248 store[credentials.identifier] = credentials; 249 await saveCredentialsStore(store); 250} 251 252/** 253 * Delete credentials for a specific identity 254 */ 255export async function deleteCredentials(identifier?: string): Promise<boolean> { 256 const store = await loadCredentialsStore(); 257 const identifiers = Object.keys(store); 258 259 if (identifiers.length === 0) { 260 return false; 261 } 262 263 // If identifier specified, delete just that one 264 if (identifier) { 265 if (!store[identifier]) { 266 return false; 267 } 268 delete store[identifier]; 269 await saveCredentialsStore(store); 270 return true; 271 } 272 273 // If only one identity, delete it (backwards compat behavior) 274 if (identifiers.length === 1 && identifiers[0]) { 275 delete store[identifiers[0]]; 276 await saveCredentialsStore(store); 277 return true; 278 } 279 280 // Multiple identities but none specified 281 return false; 282} 283 284export function getCredentialsPath(): string { 285 return CREDENTIALS_FILE; 286}