A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing

Update Authentication UX #13

merged opened by stevedylan.dev targeting main from chore/update-auth-ux

PR Includes the following updates

  1. packages/cli/src/lib/oauth-store.ts
  • Added handles?: Record<string, string> to the OAuthStore interface for DID-to-handle mapping
  • Added setOAuthHandle(did, handle) to store handles after login
  • Added getOAuthHandle(did) to retrieve stored handles
  • Added listOAuthSessionsWithHandles() to list sessions with their handles
  1. packages/cli/src/commands/login.ts
  • Imports the new functions from oauth-store
  • After successful OAuth callback, saves the handle using setOAuthHandle()
  • Updated --list to show handles: - yourhandle.bsky.social (did:plc:xxx)
  1. packages/cli/src/commands/publish.ts
  • Now uses listAllCredentials() instead of listCredentials()
  • Identity selection shows both OAuth and App Password options with appropriate labels
  • OAuth sessions display their stored handle if available
  1. packages/cli/src/commands/sync.ts
  • Same changes as publish.ts for consistent identity selection UX
  1. packages/cli/src/lib/credentials.ts
  • Updated tryLoadOAuthCredentials() to:
    • Use stored handle when looking up by DID
    • Support handle-based lookup (find OAuth session by handle)
  • Updated single-identity auto-selection to use stored handle

All changes maintain backwards compatibility - existing oauth.json files without the handles field will continue to work (handles will display as DIDs until the user re-logs).

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/sh.tangled.repo.pull/3mdzx67egf622
+159 -28
Diff #0
+13 -11
packages/cli/src/commands/login.ts
··· 11 deleteOAuthSession, 12 getOAuthStorePath, 13 listOAuthSessions, 14 } from "../lib/oauth-store"; 15 import { exitOnCancel } from "../lib/prompts"; 16 ··· 33 handler: async ({ logout, list }) => { 34 // List sessions 35 if (list) { 36 - const sessions = await listOAuthSessions(); 37 if (sessions.length === 0) { 38 log.info("No OAuth sessions stored"); 39 } else { 40 log.info("OAuth sessions:"); 41 - for (const did of sessions) { 42 - console.log(` - ${did}`); 43 } 44 } 45 return; ··· 171 new URLSearchParams(result.params!), 172 ); 173 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; 182 } 183 184 s.stop(`Logged in as ${displayName}`); 185 186 log.success(`OAuth session saved to ${getOAuthStorePath()}`);
··· 11 deleteOAuthSession, 12 getOAuthStorePath, 13 listOAuthSessions, 14 + listOAuthSessionsWithHandles, 15 + setOAuthHandle, 16 } from "../lib/oauth-store"; 17 import { exitOnCancel } from "../lib/prompts"; 18 ··· 35 handler: async ({ logout, list }) => { 36 // List sessions 37 if (list) { 38 + const sessions = await listOAuthSessionsWithHandles(); 39 if (sessions.length === 0) { 40 log.info("No OAuth sessions stored"); 41 } else { 42 log.info("OAuth sessions:"); 43 + for (const { did, handle } of sessions) { 44 + console.log(` - ${handle || did} (${did})`); 45 } 46 } 47 return; ··· 173 new URLSearchParams(result.params!), 174 ); 175 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); 181 } 182 183 + // Try to get the handle for display (use the original handle input as fallback) 184 + const displayName = handleToStore || session.did; 185 + 186 s.stop(`Logged in as ${displayName}`); 187 188 log.success(`OAuth session saved to ${getOAuthStorePath()}`);
+46 -6
packages/cli/src/commands/publish.ts
··· 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { 7 loadCredentials, 8 - listCredentials, 9 getCredentials, 10 } from "../lib/credentials"; 11 import { 12 createAgent, 13 createDocument, ··· 59 60 // If no credentials resolved, check if we need to prompt for identity selection 61 if (!credentials) { 62 - const identities = await listCredentials(); 63 if (identities.length === 0) { 64 - log.error("No credentials found. Run 'sequoia auth' first."); 65 log.info( 66 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 67 ); 68 process.exit(1); 69 } 70 71 // Multiple identities exist but none selected - prompt user 72 log.info("Multiple identities found. Select one to use:"); 73 const selected = exitOnCancel( 74 await select({ 75 message: "Identity:", 76 - options: identities.map((id) => ({ value: id, label: id })), 77 }), 78 ); 79 80 - credentials = await getCredentials(selected); 81 if (!credentials) { 82 log.error("Failed to load selected credentials."); 83 process.exit(1); 84 } 85 86 log.info( 87 - `Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`, 88 ); 89 } 90
··· 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { 7 loadCredentials, 8 + listAllCredentials, 9 getCredentials, 10 } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 import { 13 createAgent, 14 createDocument, ··· 60 61 // If no credentials resolved, check if we need to prompt for identity selection 62 if (!credentials) { 63 + const identities = await listAllCredentials(); 64 if (identities.length === 0) { 65 + log.error( 66 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 67 + ); 68 log.info( 69 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.", 70 ); 71 process.exit(1); 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 + ); 90 + 91 // Multiple identities exist but none selected - prompt user 92 log.info("Multiple identities found. Select one to use:"); 93 const selected = exitOnCancel( 94 await select({ 95 message: "Identity:", 96 + options, 97 }), 98 ); 99 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 + 117 if (!credentials) { 118 log.error("Failed to load selected credentials."); 119 process.exit(1); 120 } 121 122 + const displayId = 123 + credentials.type === "oauth" 124 + ? credentials.handle || credentials.did 125 + : credentials.identifier; 126 log.info( 127 + `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`, 128 ); 129 } 130
+41 -5
packages/cli/src/commands/sync.ts
··· 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { 7 loadCredentials, 8 - listCredentials, 9 getCredentials, 10 } from "../lib/credentials"; 11 import { createAgent, listDocuments } from "../lib/atproto"; 12 import { 13 scanContentDirectory, ··· 49 let credentials = await loadCredentials(config.identity); 50 51 if (!credentials) { 52 - const identities = await listCredentials(); 53 if (identities.length === 0) { 54 - log.error("No credentials found. Run 'sequoia auth' first."); 55 process.exit(1); 56 } 57 58 log.info("Multiple identities found. Select one to use:"); 59 const selected = exitOnCancel( 60 await select({ 61 message: "Identity:", 62 - options: identities.map((id) => ({ value: id, label: id })), 63 }), 64 ); 65 66 - credentials = await getCredentials(selected); 67 if (!credentials) { 68 log.error("Failed to load selected credentials."); 69 process.exit(1);
··· 5 import { loadConfig, loadState, saveState, findConfig } from "../lib/config"; 6 import { 7 loadCredentials, 8 + listAllCredentials, 9 getCredentials, 10 } from "../lib/credentials"; 11 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 12 import { createAgent, listDocuments } from "../lib/atproto"; 13 import { 14 scanContentDirectory, ··· 50 let credentials = await loadCredentials(config.identity); 51 52 if (!credentials) { 53 + const identities = await listAllCredentials(); 54 if (identities.length === 0) { 55 + log.error( 56 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 57 + ); 58 process.exit(1); 59 } 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 + 78 log.info("Multiple identities found. Select one to use:"); 79 const selected = exitOnCancel( 80 await select({ 81 message: "Identity:", 82 + options, 83 }), 84 ); 85 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 + 103 if (!credentials) { 104 log.error("Failed to load selected credentials."); 105 process.exit(1);
+22 -6
packages/cli/src/lib/credentials.ts
··· 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, ··· 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 ··· 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 }
··· 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, ··· 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 ··· 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 }
+37
packages/cli/src/lib/oauth-store.ts
··· 14 interface OAuthStore { 15 states: Record<string, NodeSavedState>; 16 sessions: Record<string, NodeSavedSession>; 17 } 18 19 async function fileExists(filePath: string): Promise<boolean> { ··· 122 export function getOAuthStorePath(): string { 123 return OAUTH_FILE; 124 }
··· 14 interface OAuthStore { 15 states: Record<string, NodeSavedState>; 16 sessions: Record<string, NodeSavedSession>; 17 + handles?: Record<string, string>; // DID -> handle mapping (optional for backwards compat) 18 } 19 20 async function fileExists(filePath: string): Promise<boolean> { ··· 123 export function getOAuthStorePath(): string { 124 return OAUTH_FILE; 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 + }

History

1 round 0 comments
sign up or login to add to the discussion
stevedylan.dev submitted #0
1 commit
expand
chore: updated authentication ux
1/1 success
expand
expand 0 comments
pull request successfully merged