A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
59
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 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}