diff --git a/packages/cli/src/commands/login.ts b/packages/cli/src/commands/login.ts --- a/packages/cli/src/commands/login.ts +++ b/packages/cli/src/commands/login.ts @@ -11,6 +11,8 @@ deleteOAuthSession, getOAuthStorePath, listOAuthSessions, + listOAuthSessionsWithHandles, + setOAuthHandle, } from "../lib/oauth-store"; import { exitOnCancel } from "../lib/prompts"; @@ -33,13 +35,13 @@ handler: async ({ logout, list }) => { // List sessions if (list) { - const sessions = await listOAuthSessions(); + const sessions = await listOAuthSessionsWithHandles(); if (sessions.length === 0) { log.info("No OAuth sessions stored"); } else { log.info("OAuth sessions:"); - for (const did of sessions) { - console.log(` - ${did}`); + for (const { did, handle } of sessions) { + console.log(` - ${handle || did} (${did})`); } } return; @@ -171,15 +173,15 @@ new URLSearchParams(result.params!), ); - // Try to get the handle for display (use the original handle input as fallback) - let displayName = handle; - try { - // The session should have the DID, we can use the original handle they entered - // or we could fetch the profile to get the current handle - displayName = handle.startsWith("did:") ? session.did : handle; - } catch { - displayName = session.did; + // Store the handle for friendly display + // Use the original handle input (unless it was a DID) + const handleToStore = handle.startsWith("did:") ? undefined : handle; + if (handleToStore) { + await setOAuthHandle(session.did, handleToStore); } + + // Try to get the handle for display (use the original handle input as fallback) + const displayName = handleToStore || session.did; s.stop(`Logged in as ${displayName}`); diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -5,9 +5,10 @@ import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; import { loadCredentials, - listCredentials, + listAllCredentials, getCredentials, } from "../lib/credentials"; +import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; import { createAgent, createDocument, @@ -59,32 +60,71 @@ // If no credentials resolved, check if we need to prompt for identity selection if (!credentials) { - const identities = await listCredentials(); + const identities = await listAllCredentials(); if (identities.length === 0) { - log.error("No credentials found. Run 'sequoia auth' first."); + log.error( + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", + ); log.info( "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", ); process.exit(1); } + // Build labels with handles for OAuth sessions + const options = await Promise.all( + identities.map(async (cred) => { + if (cred.type === "oauth") { + const handle = await getOAuthHandle(cred.id); + return { + value: cred.id, + label: `${handle || cred.id} (OAuth)`, + }; + } + return { + value: cred.id, + label: `${cred.id} (App Password)`, + }; + }), + ); + // Multiple identities exist but none selected - prompt user log.info("Multiple identities found. Select one to use:"); const selected = exitOnCancel( await select({ message: "Identity:", - options: identities.map((id) => ({ value: id, label: id })), + options, }), ); - credentials = await getCredentials(selected); + // Load the selected credentials + const selectedCred = identities.find((c) => c.id === selected); + if (selectedCred?.type === "oauth") { + const session = await getOAuthSession(selected); + if (session) { + const handle = await getOAuthHandle(selected); + credentials = { + type: "oauth", + did: selected, + handle: handle || selected, + pdsUrl: "https://bsky.social", + }; + } + } else { + credentials = await getCredentials(selected); + } + if (!credentials) { log.error("Failed to load selected credentials."); process.exit(1); } + const displayId = + credentials.type === "oauth" + ? credentials.handle || credentials.did + : credentials.identifier; log.info( - `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`, + `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, ); } diff --git a/packages/cli/src/commands/sync.ts b/packages/cli/src/commands/sync.ts --- a/packages/cli/src/commands/sync.ts +++ b/packages/cli/src/commands/sync.ts @@ -5,9 +5,10 @@ import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; import { loadCredentials, - listCredentials, + listAllCredentials, getCredentials, } from "../lib/credentials"; +import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; import { createAgent, listDocuments } from "../lib/atproto"; import { scanContentDirectory, @@ -49,21 +50,56 @@ let credentials = await loadCredentials(config.identity); if (!credentials) { - const identities = await listCredentials(); + const identities = await listAllCredentials(); if (identities.length === 0) { - log.error("No credentials found. Run 'sequoia auth' first."); + log.error( + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", + ); process.exit(1); } + + // Build labels with handles for OAuth sessions + const options = await Promise.all( + identities.map(async (cred) => { + if (cred.type === "oauth") { + const handle = await getOAuthHandle(cred.id); + return { + value: cred.id, + label: `${handle || cred.id} (OAuth)`, + }; + } + return { + value: cred.id, + label: `${cred.id} (App Password)`, + }; + }), + ); log.info("Multiple identities found. Select one to use:"); const selected = exitOnCancel( await select({ message: "Identity:", - options: identities.map((id) => ({ value: id, label: id })), + options, }), ); - credentials = await getCredentials(selected); + // Load the selected credentials + const selectedCred = identities.find((c) => c.id === selected); + if (selectedCred?.type === "oauth") { + const session = await getOAuthSession(selected); + if (session) { + const handle = await getOAuthHandle(selected); + credentials = { + type: "oauth", + did: selected, + handle: handle || selected, + pdsUrl: "https://bsky.social", + }; + } + } else { + credentials = await getCredentials(selected); + } + if (!credentials) { log.error("Failed to load selected credentials."); process.exit(1); diff --git a/packages/cli/src/lib/credentials.ts b/packages/cli/src/lib/credentials.ts --- a/packages/cli/src/lib/credentials.ts +++ b/packages/cli/src/lib/credentials.ts @@ -1,7 +1,12 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { getOAuthSession, listOAuthSessions } from "./oauth-store"; +import { + getOAuthHandle, + getOAuthSession, + listOAuthSessions, + listOAuthSessionsWithHandles, +} from "./oauth-store"; import type { AppPasswordCredentials, Credentials, @@ -86,18 +91,28 @@ if (profile.startsWith("did:")) { const session = await getOAuthSession(profile); if (session) { + const handle = await getOAuthHandle(profile); return { type: "oauth", did: profile, - handle: profile, // We don't have the handle stored, use DID + handle: handle || profile, pdsUrl: "https://bsky.social", // Will be resolved from DID doc }; } } - // Otherwise, we would need to check all OAuth sessions to find a matching handle, - // but handle matching isn't perfect without storing handles alongside sessions. - // For now, just return null if profile isn't a DID. + // Try to find OAuth session by handle + const sessions = await listOAuthSessionsWithHandles(); + const match = sessions.find((s) => s.handle === profile); + if (match) { + return { + type: "oauth", + did: match.did, + handle: match.handle || match.did, + pdsUrl: "https://bsky.social", + }; + } + return null; } @@ -166,10 +181,11 @@ if (oauthDids.length === 1 && oauthDids[0]) { const session = await getOAuthSession(oauthDids[0]); if (session) { + const handle = await getOAuthHandle(oauthDids[0]); return { type: "oauth", did: oauthDids[0], - handle: oauthDids[0], + handle: handle || oauthDids[0], pdsUrl: "https://bsky.social", }; } diff --git a/packages/cli/src/lib/oauth-store.ts b/packages/cli/src/lib/oauth-store.ts --- a/packages/cli/src/lib/oauth-store.ts +++ b/packages/cli/src/lib/oauth-store.ts @@ -14,6 +14,7 @@ interface OAuthStore { states: Record; sessions: Record; + handles?: Record; // DID -> handle mapping (optional for backwards compat) } async function fileExists(filePath: string): Promise { @@ -121,4 +122,40 @@ export function getOAuthStorePath(): string { return OAUTH_FILE; +} + +/** + * Store handle for an OAuth session (DID -> handle mapping) + */ +export async function setOAuthHandle( + did: string, + handle: string, +): Promise { + const store = await loadOAuthStore(); + if (!store.handles) { + store.handles = {}; + } + store.handles[did] = handle; + await saveOAuthStore(store); +} + +/** + * Get handle for an OAuth session by DID + */ +export async function getOAuthHandle(did: string): Promise { + const store = await loadOAuthStore(); + return store.handles?.[did]; +} + +/** + * List all stored OAuth sessions with their handles + */ +export async function listOAuthSessionsWithHandles(): Promise< + Array<{ did: string; handle?: string }> +> { + const store = await loadOAuthStore(); + return Object.keys(store.sessions).map((did) => ({ + did, + handle: store.handles?.[did], + })); }