chore: updated authentication ux e8c56ddb
Steve · 2026-02-04 08:07 5 file(s) · +159 −28
packages/cli/src/commands/login.ts +13 −11
11 11
	deleteOAuthSession,
12 12
	getOAuthStorePath,
13 13
	listOAuthSessions,
14 +
	listOAuthSessionsWithHandles,
15 +
	setOAuthHandle,
14 16
} from "../lib/oauth-store";
15 17
import { exitOnCancel } from "../lib/prompts";
16 18
33 35
	handler: async ({ logout, list }) => {
34 36
		// List sessions
35 37
		if (list) {
36 -
			const sessions = await listOAuthSessions();
38 +
			const sessions = await listOAuthSessionsWithHandles();
37 39
			if (sessions.length === 0) {
38 40
				log.info("No OAuth sessions stored");
39 41
			} else {
40 42
				log.info("OAuth sessions:");
41 -
				for (const did of sessions) {
42 -
					console.log(`  - ${did}`);
43 +
				for (const { did, handle } of sessions) {
44 +
					console.log(`  - ${handle || did} (${did})`);
43 45
				}
44 46
			}
45 47
			return;
171 173
				new URLSearchParams(result.params!),
172 174
			);
173 175
174 -
			// Try to get the handle for display (use the original handle input as fallback)
175 -
			let displayName = handle;
176 -
			try {
177 -
				// The session should have the DID, we can use the original handle they entered
178 -
				// or we could fetch the profile to get the current handle
179 -
				displayName = handle.startsWith("did:") ? session.did : handle;
180 -
			} catch {
181 -
				displayName = session.did;
176 +
			// Store the handle for friendly display
177 +
			// Use the original handle input (unless it was a DID)
178 +
			const handleToStore = handle.startsWith("did:") ? undefined : handle;
179 +
			if (handleToStore) {
180 +
				await setOAuthHandle(session.did, handleToStore);
182 181
			}
182 +
183 +
			// Try to get the handle for display (use the original handle input as fallback)
184 +
			const displayName = handleToStore || session.did;
183 185
184 186
			s.stop(`Logged in as ${displayName}`);
185 187
packages/cli/src/commands/publish.ts +46 −6
5 5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 6
import {
7 7
	loadCredentials,
8 -
	listCredentials,
8 +
	listAllCredentials,
9 9
	getCredentials,
10 10
} from "../lib/credentials";
11 +
import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
11 12
import {
12 13
	createAgent,
13 14
	createDocument,
59 60
60 61
		// If no credentials resolved, check if we need to prompt for identity selection
61 62
		if (!credentials) {
62 -
			const identities = await listCredentials();
63 +
			const identities = await listAllCredentials();
63 64
			if (identities.length === 0) {
64 -
				log.error("No credentials found. Run 'sequoia auth' first.");
65 +
				log.error(
66 +
					"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
67 +
				);
65 68
				log.info(
66 69
					"Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
67 70
				);
68 71
				process.exit(1);
69 72
			}
73 +
74 +
			// Build labels with handles for OAuth sessions
75 +
			const options = await Promise.all(
76 +
				identities.map(async (cred) => {
77 +
					if (cred.type === "oauth") {
78 +
						const handle = await getOAuthHandle(cred.id);
79 +
						return {
80 +
							value: cred.id,
81 +
							label: `${handle || cred.id} (OAuth)`,
82 +
						};
83 +
					}
84 +
					return {
85 +
						value: cred.id,
86 +
						label: `${cred.id} (App Password)`,
87 +
					};
88 +
				}),
89 +
			);
70 90
71 91
			// Multiple identities exist but none selected - prompt user
72 92
			log.info("Multiple identities found. Select one to use:");
73 93
			const selected = exitOnCancel(
74 94
				await select({
75 95
					message: "Identity:",
76 -
					options: identities.map((id) => ({ value: id, label: id })),
96 +
					options,
77 97
				}),
78 98
			);
79 99
80 -
			credentials = await getCredentials(selected);
100 +
			// Load the selected credentials
101 +
			const selectedCred = identities.find((c) => c.id === selected);
102 +
			if (selectedCred?.type === "oauth") {
103 +
				const session = await getOAuthSession(selected);
104 +
				if (session) {
105 +
					const handle = await getOAuthHandle(selected);
106 +
					credentials = {
107 +
						type: "oauth",
108 +
						did: selected,
109 +
						handle: handle || selected,
110 +
						pdsUrl: "https://bsky.social",
111 +
					};
112 +
				}
113 +
			} else {
114 +
				credentials = await getCredentials(selected);
115 +
			}
116 +
81 117
			if (!credentials) {
82 118
				log.error("Failed to load selected credentials.");
83 119
				process.exit(1);
84 120
			}
85 121
122 +
			const displayId =
123 +
				credentials.type === "oauth"
124 +
					? credentials.handle || credentials.did
125 +
					: credentials.identifier;
86 126
			log.info(
87 -
				`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`,
127 +
				`Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
88 128
			);
89 129
		}
90 130
packages/cli/src/commands/sync.ts +41 −5
5 5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6 6
import {
7 7
	loadCredentials,
8 -
	listCredentials,
8 +
	listAllCredentials,
9 9
	getCredentials,
10 10
} from "../lib/credentials";
11 +
import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
11 12
import { createAgent, listDocuments } from "../lib/atproto";
12 13
import {
13 14
	scanContentDirectory,
49 50
		let credentials = await loadCredentials(config.identity);
50 51
51 52
		if (!credentials) {
52 -
			const identities = await listCredentials();
53 +
			const identities = await listAllCredentials();
53 54
			if (identities.length === 0) {
54 -
				log.error("No credentials found. Run 'sequoia auth' first.");
55 +
				log.error(
56 +
					"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
57 +
				);
55 58
				process.exit(1);
56 59
			}
57 60
61 +
			// Build labels with handles for OAuth sessions
62 +
			const options = await Promise.all(
63 +
				identities.map(async (cred) => {
64 +
					if (cred.type === "oauth") {
65 +
						const handle = await getOAuthHandle(cred.id);
66 +
						return {
67 +
							value: cred.id,
68 +
							label: `${handle || cred.id} (OAuth)`,
69 +
						};
70 +
					}
71 +
					return {
72 +
						value: cred.id,
73 +
						label: `${cred.id} (App Password)`,
74 +
					};
75 +
				}),
76 +
			);
77 +
58 78
			log.info("Multiple identities found. Select one to use:");
59 79
			const selected = exitOnCancel(
60 80
				await select({
61 81
					message: "Identity:",
62 -
					options: identities.map((id) => ({ value: id, label: id })),
82 +
					options,
63 83
				}),
64 84
			);
65 85
66 -
			credentials = await getCredentials(selected);
86 +
			// Load the selected credentials
87 +
			const selectedCred = identities.find((c) => c.id === selected);
88 +
			if (selectedCred?.type === "oauth") {
89 +
				const session = await getOAuthSession(selected);
90 +
				if (session) {
91 +
					const handle = await getOAuthHandle(selected);
92 +
					credentials = {
93 +
						type: "oauth",
94 +
						did: selected,
95 +
						handle: handle || selected,
96 +
						pdsUrl: "https://bsky.social",
97 +
					};
98 +
				}
99 +
			} else {
100 +
				credentials = await getCredentials(selected);
101 +
			}
102 +
67 103
			if (!credentials) {
68 104
				log.error("Failed to load selected credentials.");
69 105
				process.exit(1);
packages/cli/src/lib/credentials.ts +22 −6
1 1
import * as fs from "node:fs/promises";
2 2
import * as os from "node:os";
3 3
import * as path from "node:path";
4 -
import { getOAuthSession, listOAuthSessions } from "./oauth-store";
4 +
import {
5 +
	getOAuthHandle,
6 +
	getOAuthSession,
7 +
	listOAuthSessions,
8 +
	listOAuthSessionsWithHandles,
9 +
} from "./oauth-store";
5 10
import type {
6 11
	AppPasswordCredentials,
7 12
	Credentials,
86 91
	if (profile.startsWith("did:")) {
87 92
		const session = await getOAuthSession(profile);
88 93
		if (session) {
94 +
			const handle = await getOAuthHandle(profile);
89 95
			return {
90 96
				type: "oauth",
91 97
				did: profile,
92 -
				handle: profile, // We don't have the handle stored, use DID
98 +
				handle: handle || profile,
93 99
				pdsUrl: "https://bsky.social", // Will be resolved from DID doc
94 100
			};
95 101
		}
96 102
	}
97 103
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.
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 +
101 116
	return null;
102 117
}
103 118
166 181
		if (oauthDids.length === 1 && oauthDids[0]) {
167 182
			const session = await getOAuthSession(oauthDids[0]);
168 183
			if (session) {
184 +
				const handle = await getOAuthHandle(oauthDids[0]);
169 185
				return {
170 186
					type: "oauth",
171 187
					did: oauthDids[0],
172 -
					handle: oauthDids[0],
188 +
					handle: handle || oauthDids[0],
173 189
					pdsUrl: "https://bsky.social",
174 190
				};
175 191
			}
packages/cli/src/lib/oauth-store.ts +37 −0
14 14
interface OAuthStore {
15 15
	states: Record<string, NodeSavedState>;
16 16
	sessions: Record<string, NodeSavedSession>;
17 +
	handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat)
17 18
}
18 19
19 20
async function fileExists(filePath: string): Promise<boolean> {
122 123
export function getOAuthStorePath(): string {
123 124
	return OAUTH_FILE;
124 125
}
126 +
127 +
/**
128 +
 * Store handle for an OAuth session (DID -> handle mapping)
129 +
 */
130 +
export async function setOAuthHandle(
131 +
	did: string,
132 +
	handle: string,
133 +
): Promise<void> {
134 +
	const store = await loadOAuthStore();
135 +
	if (!store.handles) {
136 +
		store.handles = {};
137 +
	}
138 +
	store.handles[did] = handle;
139 +
	await saveOAuthStore(store);
140 +
}
141 +
142 +
/**
143 +
 * Get handle for an OAuth session by DID
144 +
 */
145 +
export async function getOAuthHandle(did: string): Promise<string | undefined> {
146 +
	const store = await loadOAuthStore();
147 +
	return store.handles?.[did];
148 +
}
149 +
150 +
/**
151 +
 * List all stored OAuth sessions with their handles
152 +
 */
153 +
export async function listOAuthSessionsWithHandles(): Promise<
154 +
	Array<{ did: string; handle?: string }>
155 +
> {
156 +
	const store = await loadOAuthStore();
157 +
	return Object.keys(store.sessions).map((did) => ({
158 +
		did,
159 +
		handle: store.handles?.[did],
160 +
	}));
161 +
}