packages/cli/src/lib/credentials.ts 7.0 K raw
1
import * as fs from "node:fs/promises";
2
import * as os from "node:os";
3
import * as path from "node:path";
4
import {
5
	getOAuthHandle,
6
	getOAuthSession,
7
	listOAuthSessions,
8
	listOAuthSessionsWithHandles,
9
} from "./oauth-store";
10
import type {
11
	AppPasswordCredentials,
12
	Credentials,
13
	LegacyCredentials,
14
	OAuthCredentials,
15
} from "./types";
16
17
const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
18
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
19
20
// Stored credentials keyed by identifier (can be legacy or typed)
21
type CredentialsStore = Record<
22
	string,
23
	AppPasswordCredentials | LegacyCredentials
24
>;
25
26
async 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
 */
38
function 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
54
async 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
 */
78
async 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
 */
87
async 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
 */
127
export 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
 */
199
export 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
 */
211
export 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
 */
219
export 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
 */
241
export 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
 */
252
export 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
281
export function getCredentialsPath(): string {
282
	return CREDENTIALS_FILE;
283
}