A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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}