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 { getOAuthSession, listOAuthSessions } from "./oauth-store";
5
import type {
6
	AppPasswordCredentials,
7
	Credentials,
8
	LegacyCredentials,
9
	OAuthCredentials,
10
} from "./types";
11
12
const CONFIG_DIR = path.join(os.homedir(), ".config", "sequoia");
13
const CREDENTIALS_FILE = path.join(CONFIG_DIR, "credentials.json");
14
15
// Stored credentials keyed by identifier (can be legacy or typed)
16
type CredentialsStore = Record<
17
	string,
18
	AppPasswordCredentials | LegacyCredentials
19
>;
20
21
async 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
 */
33
function 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
49
async 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
 */
73
async 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
 */
82
async 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
 */
114
export 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
 */
186
export 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
 */
198
export 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
 */
206
export 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
 */
228
export 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
 */
239
export 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
268
export function getCredentialsPath(): string {
269
	return CREDENTIALS_FILE;
270
}