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 type { Credentials } from "./types";
5
6const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
7const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
8
9// Stored credentials keyed by identifier
10type CredentialsStore = Record<string, Credentials>;
11
12async function fileExists(filePath: string): Promise<boolean> {
13 try {
14 await fs.access(filePath);
15 return true;
16 } catch {
17 return false;
18 }
19}
20
21/**
22 * Load all stored credentials
23 */
24async function loadCredentialsStore(): Promise<CredentialsStore> {
25 if (!(await fileExists(CREDENTIALS_FILE))) {
26 return {};
27 }
28
29 try {
30 const content = await fs.readFile(CREDENTIALS_FILE, "utf-8");
31 const parsed = JSON.parse(content);
32
33 // Handle legacy single-credential format (migrate on read)
34 if (parsed.identifier && parsed.password) {
35 const legacy = parsed as Credentials;
36 return { [legacy.identifier]: legacy };
37 }
38
39 return parsed as CredentialsStore;
40 } catch {
41 return {};
42 }
43}
44
45/**
46 * Save the entire credentials store
47 */
48async function saveCredentialsStore(store: CredentialsStore): Promise<void> {
49 await fs.mkdir(CONFIG_DIR, { recursive: true });
50 await fs.writeFile(CREDENTIALS_FILE, JSON.stringify(store, null, 2));
51 await fs.chmod(CREDENTIALS_FILE, 0o600);
52}
53
54/**
55 * Load credentials for a specific identity or resolve which to use.
56 *
57 * Priority:
58 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD)
59 * 2. SEQUOIA_PROFILE env var - selects from stored credentials
60 * 3. projectIdentity parameter (from sequoia.json)
61 * 4. If only one identity stored, use it
62 * 5. Return null (caller should prompt user)
63 */
64export async function loadCredentials(
65 projectIdentity?: string,
66): Promise<Credentials | null> {
67 // 1. Check environment variables first (full override)
68 const envIdentifier = process.env.ATP_IDENTIFIER;
69 const envPassword = process.env.ATP_APP_PASSWORD;
70 const envPdsUrl = process.env.PDS_URL;
71
72 if (envIdentifier && envPassword) {
73 return {
74 identifier: envIdentifier,
75 password: envPassword,
76 pdsUrl: envPdsUrl || "https://bsky.social",
77 };
78 }
79
80 const store = await loadCredentialsStore();
81 const identifiers = Object.keys(store);
82
83 if (identifiers.length === 0) {
84 return null;
85 }
86
87 // 2. SEQUOIA_PROFILE env var
88 const profileEnv = process.env.SEQUOIA_PROFILE;
89 if (profileEnv && store[profileEnv]) {
90 return store[profileEnv];
91 }
92
93 // 3. Project-specific identity (from sequoia.json)
94 if (projectIdentity && store[projectIdentity]) {
95 return store[projectIdentity];
96 }
97
98 // 4. If only one identity, use it
99 if (identifiers.length === 1 && identifiers[0]) {
100 return store[identifiers[0]] ?? null;
101 }
102
103 // Multiple identities exist but none selected
104 return null;
105}
106
107/**
108 * Get a specific identity by identifier
109 */
110export async function getCredentials(
111 identifier: string,
112): Promise<Credentials | null> {
113 const store = await loadCredentialsStore();
114 return store[identifier] || null;
115}
116
117/**
118 * List all stored identities
119 */
120export async function listCredentials(): Promise<string[]> {
121 const store = await loadCredentialsStore();
122 return Object.keys(store);
123}
124
125/**
126 * Save credentials for an identity (adds or updates)
127 */
128export async function saveCredentials(credentials: Credentials): Promise<void> {
129 const store = await loadCredentialsStore();
130 store[credentials.identifier] = credentials;
131 await saveCredentialsStore(store);
132}
133
134/**
135 * Delete credentials for a specific identity
136 */
137export async function deleteCredentials(identifier?: string): Promise<boolean> {
138 const store = await loadCredentialsStore();
139 const identifiers = Object.keys(store);
140
141 if (identifiers.length === 0) {
142 return false;
143 }
144
145 // If identifier specified, delete just that one
146 if (identifier) {
147 if (!store[identifier]) {
148 return false;
149 }
150 delete store[identifier];
151 await saveCredentialsStore(store);
152 return true;
153 }
154
155 // If only one identity, delete it (backwards compat behavior)
156 if (identifiers.length === 1 && identifiers[0]) {
157 delete store[identifiers[0]];
158 await saveCredentialsStore(store);
159 return true;
160 }
161
162 // Multiple identities but none specified
163 return false;
164}
165
166export function getCredentialsPath(): string {
167 return CREDENTIALS_FILE;
168}