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