A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.3.3 283 lines 7.2 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 }; 100 } 101 } 102 103 // Try to find OAuth session by handle 104 const sessions = await listOAuthSessionsWithHandles(); 105 const match = sessions.find((s) => s.handle === profile); 106 if (match) { 107 return { 108 type: "oauth", 109 did: match.did, 110 handle: match.handle || match.did, 111 }; 112 } 113 114 return null; 115} 116 117/** 118 * Load credentials for a specific identity or resolve which to use. 119 * 120 * Priority: 121 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD) 122 * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID) 123 * 3. projectIdentity parameter (from sequoia.json) 124 * 4. If only one identity stored (app-password or OAuth), use it 125 * 5. Return null (caller should prompt user) 126 */ 127export async function loadCredentials( 128 projectIdentity?: string, 129): Promise<Credentials | null> { 130 // 1. Check environment variables first (full override) 131 const envIdentifier = process.env.ATP_IDENTIFIER; 132 const envPassword = process.env.ATP_APP_PASSWORD; 133 const envPdsUrl = process.env.PDS_URL; 134 135 if (envIdentifier && envPassword) { 136 return { 137 type: "app-password", 138 identifier: envIdentifier, 139 password: envPassword, 140 pdsUrl: envPdsUrl || "https://bsky.social", 141 }; 142 } 143 144 const store = await loadCredentialsStore(); 145 const appPasswordIds = Object.keys(store); 146 const oauthDids = await listOAuthSessions(); 147 148 // 2. SEQUOIA_PROFILE env var 149 const profileEnv = process.env.SEQUOIA_PROFILE; 150 if (profileEnv) { 151 // Try app-password credentials first 152 if (store[profileEnv]) { 153 return normalizeCredentials(store[profileEnv]); 154 } 155 // Try OAuth session (profile could be a DID) 156 const oauth = await tryLoadOAuthCredentials(profileEnv); 157 if (oauth) { 158 return oauth; 159 } 160 } 161 162 // 3. Project-specific identity (from sequoia.json) 163 if (projectIdentity) { 164 if (store[projectIdentity]) { 165 return normalizeCredentials(store[projectIdentity]); 166 } 167 const oauth = await tryLoadOAuthCredentials(projectIdentity); 168 if (oauth) { 169 return oauth; 170 } 171 } 172 173 // 4. If only one identity total, use it 174 const totalIdentities = appPasswordIds.length + oauthDids.length; 175 if (totalIdentities === 1) { 176 if (appPasswordIds.length === 1 && appPasswordIds[0]) { 177 return normalizeCredentials(store[appPasswordIds[0]]!); 178 } 179 if (oauthDids.length === 1 && oauthDids[0]) { 180 const session = await getOAuthSession(oauthDids[0]); 181 if (session) { 182 const handle = await getOAuthHandle(oauthDids[0]); 183 return { 184 type: "oauth", 185 did: oauthDids[0], 186 handle: handle || oauthDids[0], 187 }; 188 } 189 } 190 } 191 192 // Multiple identities exist but none selected, or no identities 193 return null; 194} 195 196/** 197 * Get a specific identity by identifier (app-password only) 198 */ 199export async function getCredentials( 200 identifier: string, 201): Promise<AppPasswordCredentials | null> { 202 const store = await loadCredentialsStore(); 203 const creds = store[identifier]; 204 if (!creds) return null; 205 return normalizeCredentials(creds); 206} 207 208/** 209 * List all stored app-password identities 210 */ 211export async function listCredentials(): Promise<string[]> { 212 const store = await loadCredentialsStore(); 213 return Object.keys(store); 214} 215 216/** 217 * List all credentials (both app-password and OAuth) 218 */ 219export async function listAllCredentials(): Promise< 220 Array<{ id: string; type: "app-password" | "oauth" }> 221> { 222 const store = await loadCredentialsStore(); 223 const oauthDids = await listOAuthSessions(); 224 225 const result: Array<{ id: string; type: "app-password" | "oauth" }> = []; 226 227 for (const id of Object.keys(store)) { 228 result.push({ id, type: "app-password" }); 229 } 230 231 for (const did of oauthDids) { 232 result.push({ id: did, type: "oauth" }); 233 } 234 235 return result; 236} 237 238/** 239 * Save app-password credentials for an identity (adds or updates) 240 */ 241export async function saveCredentials( 242 credentials: AppPasswordCredentials, 243): Promise<void> { 244 const store = await loadCredentialsStore(); 245 store[credentials.identifier] = credentials; 246 await saveCredentialsStore(store); 247} 248 249/** 250 * Delete credentials for a specific identity 251 */ 252export async function deleteCredentials(identifier?: string): Promise<boolean> { 253 const store = await loadCredentialsStore(); 254 const identifiers = Object.keys(store); 255 256 if (identifiers.length === 0) { 257 return false; 258 } 259 260 // If identifier specified, delete just that one 261 if (identifier) { 262 if (!store[identifier]) { 263 return false; 264 } 265 delete store[identifier]; 266 await saveCredentialsStore(store); 267 return true; 268 } 269 270 // If only one identity, delete it (backwards compat behavior) 271 if (identifiers.length === 1 && identifiers[0]) { 272 delete store[identifiers[0]]; 273 await saveCredentialsStore(store); 274 return true; 275 } 276 277 // Multiple identities but none specified 278 return false; 279} 280 281export function getCredentialsPath(): string { 282 return CREDENTIALS_FILE; 283}