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

Compare changes

Choose any two refs to compare.

+180 -23
+1
.gitignore
··· 35 35 36 36 # Bun lockfile - keep but binary cache 37 37 bun.lockb 38 + packages/ui
+18 -1
CHANGELOG.md
··· 1 + ## [0.3.3.] - 2026-02-04 2 + 3 + ### โš™๏ธ Miscellaneous Tasks 4 + 5 + - Cleaned up remaining auth implementations 6 + - Format 7 + 8 + ## [0.3.2] - 2026-02-05 9 + 10 + ### ๐Ÿ› Bug Fixes 11 + 12 + - Fixed issue with auth selection in init command 13 + 14 + ### โš™๏ธ Miscellaneous Tasks 15 + 16 + - Release 0.3.2 1 17 ## [0.3.1] - 2026-02-04 2 18 3 19 ### ๐Ÿ› Bug Fixes ··· 7 23 ### โš™๏ธ Miscellaneous Tasks 8 24 9 25 - Updated authentication ux 10 - 26 + - Release 0.3.1 27 + - Bumped version 11 28 ## [0.3.0] - 2026-02-04 12 29 13 30 ### ๐Ÿš€ Features
+14 -1
bun.lock
··· 24 24 }, 25 25 "packages/cli": { 26 26 "name": "sequoia-cli", 27 - "version": "0.2.1", 27 + "version": "0.3.2", 28 28 "bin": { 29 29 "sequoia": "dist/index.js", 30 30 }, ··· 41 41 "devDependencies": { 42 42 "@biomejs/biome": "^2.3.13", 43 43 "@types/mime-types": "^3.0.1", 44 + "@types/node": "^20", 45 + }, 46 + "peerDependencies": { 47 + "typescript": "^5", 48 + }, 49 + }, 50 + "packages/ui": { 51 + "name": "sequoia-ui", 52 + "version": "0.1.0", 53 + "devDependencies": { 54 + "@biomejs/biome": "^2.3.13", 44 55 "@types/node": "^20", 45 56 }, 46 57 "peerDependencies": { ··· 1358 1369 "send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="], 1359 1370 1360 1371 "sequoia-cli": ["sequoia-cli@workspace:packages/cli"], 1372 + 1373 + "sequoia-ui": ["sequoia-ui@workspace:packages/ui"], 1361 1374 1362 1375 "serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="], 1363 1376
+1 -1
packages/cli/package.json
··· 1 1 { 2 2 "name": "sequoia-cli", 3 - "version": "0.3.1", 3 + "version": "0.3.3", 4 4 "type": "module", 5 5 "bin": { 6 6 "sequoia": "dist/index.js"
+24 -6
packages/cli/src/commands/init.ts
··· 13 13 } from "@clack/prompts"; 14 14 import * as path from "node:path"; 15 15 import { findConfig, generateConfigTemplate } from "../lib/config"; 16 - import { loadCredentials } from "../lib/credentials"; 16 + import { loadCredentials, listAllCredentials } from "../lib/credentials"; 17 17 import { createAgent, createPublication } from "../lib/atproto"; 18 + import { selectCredential } from "../lib/credential-select"; 18 19 import type { FrontmatterMapping, BlueskyConfig } from "../lib/types"; 19 20 20 21 async function fileExists(filePath: string): Promise<boolean> { ··· 186 187 } 187 188 188 189 let publicationUri: string; 189 - const credentials = await loadCredentials(); 190 + let credentials = await loadCredentials(); 190 191 191 192 if (publicationChoice === "create") { 192 193 // Need credentials to create a publication 193 194 if (!credentials) { 195 + // Check if there are multiple identities - if so, prompt to select 196 + const allCredentials = await listAllCredentials(); 197 + if (allCredentials.length > 1) { 198 + credentials = await selectCredential(allCredentials); 199 + } else if (allCredentials.length === 1) { 200 + // Single credential exists but couldn't be loaded - try to load it explicitly 201 + credentials = await selectCredential(allCredentials); 202 + } else { 203 + log.error( 204 + "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 205 + ); 206 + process.exit(1); 207 + } 208 + } 209 + 210 + if (!credentials) { 194 211 log.error( 195 - "You must authenticate first. Run 'sequoia auth' before creating a publication.", 212 + "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 196 213 ); 197 214 process.exit(1); 198 215 } ··· 206 223 } catch (_error) { 207 224 s.stop("Failed to connect"); 208 225 log.error( 209 - "Failed to connect. Check your credentials with 'sequoia auth'.", 226 + "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 210 227 ); 211 228 process.exit(1); 212 229 } ··· 308 325 }; 309 326 } 310 327 311 - // Get PDS URL from credentials (already loaded earlier) 312 - const pdsUrl = credentials?.pdsUrl; 328 + // Get PDS URL from credentials (only available for app-password auth) 329 + const pdsUrl = 330 + credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 313 331 314 332 // Generate config file 315 333 const configContent = generateConfigTemplate({
+3 -2
packages/cli/src/commands/publish.ts
··· 107 107 type: "oauth", 108 108 did: selected, 109 109 handle: handle || selected, 110 - pdsUrl: "https://bsky.social", 111 110 }; 112 111 } 113 112 } else { ··· 246 245 } 247 246 248 247 // Create agent 249 - s.start(`Connecting to ${credentials.pdsUrl}...`); 248 + const connectingTo = 249 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 250 + s.start(`Connecting as ${connectingTo}...`); 250 251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 251 252 try { 252 253 agent = await createAgent(credentials);
+3 -2
packages/cli/src/commands/sync.ts
··· 93 93 type: "oauth", 94 94 did: selected, 95 95 handle: handle || selected, 96 - pdsUrl: "https://bsky.social", 97 96 }; 98 97 } 99 98 } else { ··· 108 107 109 108 // Create agent 110 109 const s = spinner(); 111 - s.start(`Connecting to ${credentials.pdsUrl}...`); 110 + const connectingTo = 111 + credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl; 112 + s.start(`Connecting as ${connectingTo}...`); 112 113 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 113 114 try { 114 115 agent = await createAgent(credentials);
+60 -5
packages/cli/src/commands/update.ts
··· 11 11 log, 12 12 } from "@clack/prompts"; 13 13 import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; 14 - import { loadCredentials } from "../lib/credentials"; 14 + import { 15 + loadCredentials, 16 + listAllCredentials, 17 + getCredentials, 18 + } from "../lib/credentials"; 19 + import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store"; 15 20 import { createAgent, getPublication, updatePublication } from "../lib/atproto"; 16 21 import { exitOnCancel } from "../lib/prompts"; 17 22 import type { ··· 438 443 439 444 async function updatePublicationFlow(config: PublisherConfig): Promise<void> { 440 445 // Load credentials 441 - const credentials = await loadCredentials(config.identity); 446 + let credentials = await loadCredentials(config.identity); 447 + 442 448 if (!credentials) { 443 - log.error( 444 - "No credentials found. Run 'sequoia auth' or 'sequoia login' first.", 449 + const identities = await listAllCredentials(); 450 + if (identities.length === 0) { 451 + log.error( 452 + "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", 453 + ); 454 + process.exit(1); 455 + } 456 + 457 + // Build labels with handles for OAuth sessions 458 + const options = await Promise.all( 459 + identities.map(async (cred) => { 460 + if (cred.type === "oauth") { 461 + const handle = await getOAuthHandle(cred.id); 462 + return { 463 + value: cred.id, 464 + label: `${handle || cred.id} (OAuth)`, 465 + }; 466 + } 467 + return { 468 + value: cred.id, 469 + label: `${cred.id} (App Password)`, 470 + }; 471 + }), 445 472 ); 446 - process.exit(1); 473 + 474 + log.info("Multiple identities found. Select one to use:"); 475 + const selected = exitOnCancel( 476 + await select({ 477 + message: "Identity:", 478 + options, 479 + }), 480 + ); 481 + 482 + // Load the selected credentials 483 + const selectedCred = identities.find((c) => c.id === selected); 484 + if (selectedCred?.type === "oauth") { 485 + const session = await getOAuthSession(selected); 486 + if (session) { 487 + const handle = await getOAuthHandle(selected); 488 + credentials = { 489 + type: "oauth", 490 + did: selected, 491 + handle: handle || selected, 492 + }; 493 + } 494 + } else { 495 + credentials = await getCredentials(selected); 496 + } 497 + 498 + if (!credentials) { 499 + log.error("Failed to load selected credentials."); 500 + process.exit(1); 501 + } 447 502 } 448 503 449 504 const s = spinner();
+1 -1
packages/cli/src/index.ts
··· 35 35 36 36 > https://tangled.org/stevedylan.dev/sequoia 37 37 `, 38 - version: "0.3.1", 38 + version: "0.3.3", 39 39 cmds: { 40 40 auth: authCommand, 41 41 init: initCommand,
+54
packages/cli/src/lib/credential-select.ts
··· 1 + import { select } from "@clack/prompts"; 2 + import { getOAuthHandle, getOAuthSession } from "./oauth-store"; 3 + import { getCredentials } from "./credentials"; 4 + import type { Credentials } from "./types"; 5 + import { exitOnCancel } from "./prompts"; 6 + 7 + /** 8 + * Prompt user to select from multiple credentials 9 + */ 10 + export async function selectCredential( 11 + allCredentials: Array<{ id: string; type: "app-password" | "oauth" }>, 12 + ): Promise<Credentials | null> { 13 + // Build options with friendly labels 14 + const options = await Promise.all( 15 + allCredentials.map(async ({ id, type }) => { 16 + let label = id; 17 + if (type === "oauth") { 18 + const handle = await getOAuthHandle(id); 19 + label = handle ? `${handle} (${id})` : id; 20 + } 21 + return { 22 + value: { id, type }, 23 + label: `${label} [${type}]`, 24 + }; 25 + }), 26 + ); 27 + 28 + const selected = exitOnCancel( 29 + await select({ 30 + message: "Multiple identities found. Select one:", 31 + options, 32 + }), 33 + ); 34 + 35 + // Load the full credentials for the selected identity 36 + if (selected.type === "oauth") { 37 + const session = await getOAuthSession(selected.id); 38 + if (session) { 39 + const handle = await getOAuthHandle(selected.id); 40 + return { 41 + type: "oauth", 42 + did: selected.id, 43 + handle: handle || selected.id, 44 + }; 45 + } 46 + } else { 47 + const creds = await getCredentials(selected.id); 48 + if (creds) { 49 + return creds; 50 + } 51 + } 52 + 53 + return null; 54 + }
-3
packages/cli/src/lib/credentials.ts
··· 96 96 type: "oauth", 97 97 did: profile, 98 98 handle: handle || profile, 99 - pdsUrl: "https://bsky.social", // Will be resolved from DID doc 100 99 }; 101 100 } 102 101 } ··· 109 108 type: "oauth", 110 109 did: match.did, 111 110 handle: match.handle || match.did, 112 - pdsUrl: "https://bsky.social", 113 111 }; 114 112 } 115 113 ··· 186 184 type: "oauth", 187 185 did: oauthDids[0], 188 186 handle: handle || oauthDids[0], 189 - pdsUrl: "https://bsky.social", 190 187 }; 191 188 } 192 189 }
+1 -1
packages/cli/src/lib/types.ts
··· 54 54 } 55 55 56 56 // OAuth credentials (references stored OAuth session) 57 + // Note: pdsUrl is not needed for OAuth - the OAuth client resolves PDS from the DID 57 58 export interface OAuthCredentials { 58 59 type: "oauth"; 59 60 did: string; 60 61 handle: string; 61 - pdsUrl: string; 62 62 } 63 63 64 64 // Union type for all credential types