packages/cli/src/lib/credentials.ts 7.2 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
				pdsUrl: "https://bsky.social", // Will be resolved from DID doc
100
			};
101
		}
102
	}
103
104
	// Try to find OAuth session by handle
105
	const sessions = await listOAuthSessionsWithHandles();
106
	const match = sessions.find((s) => s.handle === profile);
107
	if (match) {
108
		return {
109
			type: "oauth",
110
			did: match.did,
111
			handle: match.handle || match.did,
112
			pdsUrl: "https://bsky.social",
113
		};
114
	}
115
116
	return null;
117
}
118
119
/**
120
 * Load credentials for a specific identity or resolve which to use.
121
 *
122
 * Priority:
123
 * 1. Full env vars (ATP_IDENTIFIER + ATP_APP_PASSWORD)
124
 * 2. SEQUOIA_PROFILE env var - selects from stored credentials (app-password or OAuth DID)
125
 * 3. projectIdentity parameter (from sequoia.json)
126
 * 4. If only one identity stored (app-password or OAuth), use it
127
 * 5. Return null (caller should prompt user)
128
 */
129
export async function loadCredentials(
130
	projectIdentity?: string,
131
): Promise<Credentials | null> {
132
	// 1. Check environment variables first (full override)
133
	const envIdentifier = process.env.ATP_IDENTIFIER;
134
	const envPassword = process.env.ATP_APP_PASSWORD;
135
	const envPdsUrl = process.env.PDS_URL;
136
137
	if (envIdentifier && envPassword) {
138
		return {
139
			type: "app-password",
140
			identifier: envIdentifier,
141
			password: envPassword,
142
			pdsUrl: envPdsUrl || "https://bsky.social",
143
		};
144
	}
145
146
	const store = await loadCredentialsStore();
147
	const appPasswordIds = Object.keys(store);
148
	const oauthDids = await listOAuthSessions();
149
150
	// 2. SEQUOIA_PROFILE env var
151
	const profileEnv = process.env.SEQUOIA_PROFILE;
152
	if (profileEnv) {
153
		// Try app-password credentials first
154
		if (store[profileEnv]) {
155
			return normalizeCredentials(store[profileEnv]);
156
		}
157
		// Try OAuth session (profile could be a DID)
158
		const oauth = await tryLoadOAuthCredentials(profileEnv);
159
		if (oauth) {
160
			return oauth;
161
		}
162
	}
163
164
	// 3. Project-specific identity (from sequoia.json)
165
	if (projectIdentity) {
166
		if (store[projectIdentity]) {
167
			return normalizeCredentials(store[projectIdentity]);
168
		}
169
		const oauth = await tryLoadOAuthCredentials(projectIdentity);
170
		if (oauth) {
171
			return oauth;
172
		}
173
	}
174
175
	// 4. If only one identity total, use it
176
	const totalIdentities = appPasswordIds.length + oauthDids.length;
177
	if (totalIdentities === 1) {
178
		if (appPasswordIds.length === 1 && appPasswordIds[0]) {
179
			return normalizeCredentials(store[appPasswordIds[0]]!);
180
		}
181
		if (oauthDids.length === 1 && oauthDids[0]) {
182
			const session = await getOAuthSession(oauthDids[0]);
183
			if (session) {
184
				const handle = await getOAuthHandle(oauthDids[0]);
185
				return {
186
					type: "oauth",
187
					did: oauthDids[0],
188
					handle: handle || oauthDids[0],
189
					pdsUrl: "https://bsky.social",
190
				};
191
			}
192
		}
193
	}
194
195
	// Multiple identities exist but none selected, or no identities
196
	return null;
197
}
198
199
/**
200
 * Get a specific identity by identifier (app-password only)
201
 */
202
export async function getCredentials(
203
	identifier: string,
204
): Promise<AppPasswordCredentials | null> {
205
	const store = await loadCredentialsStore();
206
	const creds = store[identifier];
207
	if (!creds) return null;
208
	return normalizeCredentials(creds);
209
}
210
211
/**
212
 * List all stored app-password identities
213
 */
214
export async function listCredentials(): Promise<string[]> {
215
	const store = await loadCredentialsStore();
216
	return Object.keys(store);
217
}
218
219
/**
220
 * List all credentials (both app-password and OAuth)
221
 */
222
export async function listAllCredentials(): Promise<
223
	Array<{ id: string; type: "app-password" | "oauth" }>
224
> {
225
	const store = await loadCredentialsStore();
226
	const oauthDids = await listOAuthSessions();
227
228
	const result: Array<{ id: string; type: "app-password" | "oauth" }> = [];
229
230
	for (const id of Object.keys(store)) {
231
		result.push({ id, type: "app-password" });
232
	}
233
234
	for (const did of oauthDids) {
235
		result.push({ id: did, type: "oauth" });
236
	}
237
238
	return result;
239
}
240
241
/**
242
 * Save app-password credentials for an identity (adds or updates)
243
 */
244
export async function saveCredentials(
245
	credentials: AppPasswordCredentials,
246
): Promise<void> {
247
	const store = await loadCredentialsStore();
248
	store[credentials.identifier] = credentials;
249
	await saveCredentialsStore(store);
250
}
251
252
/**
253
 * Delete credentials for a specific identity
254
 */
255
export async function deleteCredentials(identifier?: string): Promise<boolean> {
256
	const store = await loadCredentialsStore();
257
	const identifiers = Object.keys(store);
258
259
	if (identifiers.length === 0) {
260
		return false;
261
	}
262
263
	// If identifier specified, delete just that one
264
	if (identifier) {
265
		if (!store[identifier]) {
266
			return false;
267
		}
268
		delete store[identifier];
269
		await saveCredentialsStore(store);
270
		return true;
271
	}
272
273
	// If only one identity, delete it (backwards compat behavior)
274
	if (identifiers.length === 1 && identifiers[0]) {
275
		delete store[identifiers[0]];
276
		await saveCredentialsStore(store);
277
		return true;
278
	}
279
280
	// Multiple identities but none specified
281
	return false;
282
}
283
284
export function getCredentialsPath(): string {
285
	return CREDENTIALS_FILE;
286
}