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 {
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}