A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 745 lines 19 kB view raw
1import { Agent, AtpAgent } from "@atproto/api"; 2import * as mimeTypes from "mime-types"; 3import * as fs from "node:fs/promises"; 4import * as path from "node:path"; 5import { stripMarkdownForText } from "./markdown"; 6import { getOAuthClient } from "./oauth-client"; 7import type { 8 BlobObject, 9 BlogPost, 10 Credentials, 11 PublicationRecord, 12 PublisherConfig, 13 StrongRef, 14} from "./types"; 15import { isAppPasswordCredentials, isOAuthCredentials } from "./types"; 16 17/** 18 * Type guard to check if a record value is a DocumentRecord 19 */ 20function isDocumentRecord(value: unknown): value is DocumentRecord { 21 if (!value || typeof value !== "object") return false; 22 const v = value as Record<string, unknown>; 23 return ( 24 v.$type === "site.standard.document" && 25 typeof v.title === "string" && 26 typeof v.site === "string" && 27 typeof v.path === "string" && 28 typeof v.textContent === "string" && 29 typeof v.publishedAt === "string" 30 ); 31} 32 33async function fileExists(filePath: string): Promise<boolean> { 34 try { 35 await fs.access(filePath); 36 return true; 37 } catch { 38 return false; 39 } 40} 41 42/** 43 * Resolve a handle to a DID 44 */ 45export async function resolveHandleToDid(handle: string): Promise<string> { 46 if (handle.startsWith("did:")) { 47 return handle; 48 } 49 50 // Try to resolve handle via Bluesky API 51 const resolveUrl = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`; 52 const resolveResponse = await fetch(resolveUrl); 53 if (!resolveResponse.ok) { 54 throw new Error("Could not resolve handle"); 55 } 56 const resolveData = (await resolveResponse.json()) as { did: string }; 57 return resolveData.did; 58} 59 60export async function resolveHandleToPDS(handle: string): Promise<string> { 61 // First, resolve the handle to a DID 62 const did = await resolveHandleToDid(handle); 63 64 // Now resolve the DID to get the PDS URL from the DID document 65 let pdsUrl: string | undefined; 66 67 if (did.startsWith("did:plc:")) { 68 // Fetch DID document from plc.directory 69 const didDocUrl = `https://plc.directory/${did}`; 70 const didDocResponse = await fetch(didDocUrl); 71 if (!didDocResponse.ok) { 72 throw new Error("Could not fetch DID document"); 73 } 74 const didDoc = (await didDocResponse.json()) as { 75 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 76 }; 77 78 // Find the PDS service endpoint 79 const pdsService = didDoc.service?.find( 80 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 81 ); 82 pdsUrl = pdsService?.serviceEndpoint; 83 } else if (did.startsWith("did:web:")) { 84 // For did:web, fetch the DID document from the domain 85 const domain = did.replace("did:web:", ""); 86 const didDocUrl = `https://${domain}/.well-known/did.json`; 87 const didDocResponse = await fetch(didDocUrl); 88 if (!didDocResponse.ok) { 89 throw new Error("Could not fetch DID document"); 90 } 91 const didDoc = (await didDocResponse.json()) as { 92 service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 93 }; 94 95 const pdsService = didDoc.service?.find( 96 (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 97 ); 98 pdsUrl = pdsService?.serviceEndpoint; 99 } 100 101 if (!pdsUrl) { 102 throw new Error("Could not find PDS URL for user"); 103 } 104 105 return pdsUrl; 106} 107 108export interface CreatePublicationOptions { 109 url: string; 110 name: string; 111 description?: string; 112 iconPath?: string; 113 showInDiscover?: boolean; 114} 115 116export async function createAgent(credentials: Credentials): Promise<Agent> { 117 if (isOAuthCredentials(credentials)) { 118 // OAuth flow - restore session from stored tokens 119 const client = await getOAuthClient(); 120 try { 121 const oauthSession = await client.restore(credentials.did); 122 // Wrap the OAuth session in an Agent which provides the atproto API 123 return new Agent(oauthSession); 124 } catch (error) { 125 if (error instanceof Error) { 126 // Check for common OAuth errors 127 if ( 128 error.message.includes("expired") || 129 error.message.includes("revoked") 130 ) { 131 throw new Error( 132 `OAuth session expired or revoked. Please run 'sequoia login' to re-authenticate.`, 133 ); 134 } 135 } 136 throw error; 137 } 138 } 139 140 // App password flow 141 if (!isAppPasswordCredentials(credentials)) { 142 throw new Error("Invalid credential type"); 143 } 144 const agent = new AtpAgent({ service: credentials.pdsUrl }); 145 146 await agent.login({ 147 identifier: credentials.identifier, 148 password: credentials.password, 149 }); 150 151 return agent; 152} 153 154export async function uploadImage( 155 agent: Agent, 156 imagePath: string, 157): Promise<BlobObject | undefined> { 158 if (!(await fileExists(imagePath))) { 159 return undefined; 160 } 161 162 try { 163 const imageBuffer = await fs.readFile(imagePath); 164 const mimeType = mimeTypes.lookup(imagePath) || "application/octet-stream"; 165 166 const response = await agent.com.atproto.repo.uploadBlob( 167 new Uint8Array(imageBuffer), 168 { 169 encoding: mimeType, 170 }, 171 ); 172 173 return { 174 $type: "blob", 175 ref: { 176 $link: response.data.blob.ref.toString(), 177 }, 178 mimeType, 179 size: imageBuffer.byteLength, 180 }; 181 } catch (error) { 182 console.error(`Error uploading image ${imagePath}:`, error); 183 return undefined; 184 } 185} 186 187export async function resolveImagePath( 188 ogImage: string, 189 imagesDir: string | undefined, 190 contentDir: string, 191): Promise<string | null> { 192 // Try multiple resolution strategies 193 194 // 1. If imagesDir is specified, look there 195 if (imagesDir) { 196 // Get the base name of the images directory (e.g., "blog-images" from "public/blog-images") 197 const imagesDirBaseName = path.basename(imagesDir); 198 199 // Check if ogImage contains the images directory name and extract the relative path 200 // e.g., "/blog-images/other/file.png" with imagesDirBaseName "blog-images" -> "other/file.png" 201 const imagesDirIndex = ogImage.indexOf(imagesDirBaseName); 202 let relativePath: string; 203 204 if (imagesDirIndex !== -1) { 205 // Extract everything after "blog-images/" 206 const afterImagesDir = ogImage.substring( 207 imagesDirIndex + imagesDirBaseName.length, 208 ); 209 // Remove leading slash if present 210 relativePath = afterImagesDir.replace(/^[/\\]/, ""); 211 } else { 212 // Fall back to just the filename 213 relativePath = path.basename(ogImage); 214 } 215 216 const imagePath = path.join(imagesDir, relativePath); 217 if (await fileExists(imagePath)) { 218 const stat = await fs.stat(imagePath); 219 if (stat.size > 0) { 220 return imagePath; 221 } 222 } 223 } 224 225 // 2. Try the ogImage path directly (if it's absolute) 226 if (path.isAbsolute(ogImage)) { 227 return ogImage; 228 } 229 230 // 3. Try relative to content directory 231 const contentRelative = path.join(contentDir, ogImage); 232 if (await fileExists(contentRelative)) { 233 const stat = await fs.stat(contentRelative); 234 if (stat.size > 0) { 235 return contentRelative; 236 } 237 } 238 239 return null; 240} 241 242export async function createDocument( 243 agent: Agent, 244 post: BlogPost, 245 config: PublisherConfig, 246 coverImage?: BlobObject, 247): Promise<string> { 248 const pathPrefix = config.pathPrefix || "/posts"; 249 const postPath = `${pathPrefix}/${post.slug}`; 250 const publishDate = new Date(post.frontmatter.publishDate); 251 252 // Determine textContent: use configured field from frontmatter, or fallback to markdown body 253 let textContent: string; 254 if ( 255 config.textContentField && 256 post.rawFrontmatter?.[config.textContentField] 257 ) { 258 textContent = String(post.rawFrontmatter[config.textContentField]); 259 } else { 260 textContent = stripMarkdownForText(post.content); 261 } 262 263 const record: Record<string, unknown> = { 264 $type: "site.standard.document", 265 title: post.frontmatter.title, 266 site: config.publicationUri, 267 path: postPath, 268 textContent: textContent.slice(0, 10000), 269 publishedAt: publishDate.toISOString(), 270 canonicalUrl: `${config.siteUrl}${postPath}`, 271 }; 272 273 if (post.frontmatter.description) { 274 record.description = post.frontmatter.description; 275 } 276 277 if (coverImage) { 278 record.coverImage = coverImage; 279 } 280 281 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 282 record.tags = post.frontmatter.tags; 283 } 284 285 const response = await agent.com.atproto.repo.createRecord({ 286 repo: agent.did!, 287 collection: "site.standard.document", 288 record, 289 }); 290 291 return response.data.uri; 292} 293 294export async function updateDocument( 295 agent: Agent, 296 post: BlogPost, 297 atUri: string, 298 config: PublisherConfig, 299 coverImage?: BlobObject, 300): Promise<void> { 301 // Parse the atUri to get the collection and rkey 302 // Format: at://did:plc:xxx/collection/rkey 303 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 304 if (!uriMatch) { 305 throw new Error(`Invalid atUri format: ${atUri}`); 306 } 307 308 const [, , collection, rkey] = uriMatch; 309 310 const pathPrefix = config.pathPrefix || "/posts"; 311 const postPath = `${pathPrefix}/${post.slug}`; 312 const publishDate = new Date(post.frontmatter.publishDate); 313 314 // Determine textContent: use configured field from frontmatter, or fallback to markdown body 315 let textContent: string; 316 if ( 317 config.textContentField && 318 post.rawFrontmatter?.[config.textContentField] 319 ) { 320 textContent = String(post.rawFrontmatter[config.textContentField]); 321 } else { 322 textContent = stripMarkdownForText(post.content); 323 } 324 325 const record: Record<string, unknown> = { 326 $type: "site.standard.document", 327 title: post.frontmatter.title, 328 site: config.publicationUri, 329 path: postPath, 330 textContent: textContent.slice(0, 10000), 331 publishedAt: publishDate.toISOString(), 332 canonicalUrl: `${config.siteUrl}${postPath}`, 333 }; 334 335 if (post.frontmatter.description) { 336 record.description = post.frontmatter.description; 337 } 338 339 if (coverImage) { 340 record.coverImage = coverImage; 341 } 342 343 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 344 record.tags = post.frontmatter.tags; 345 } 346 347 await agent.com.atproto.repo.putRecord({ 348 repo: agent.did!, 349 collection: collection!, 350 rkey: rkey!, 351 record, 352 }); 353} 354 355export function parseAtUri( 356 atUri: string, 357): { did: string; collection: string; rkey: string } | null { 358 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 359 if (!match) return null; 360 return { 361 did: match[1]!, 362 collection: match[2]!, 363 rkey: match[3]!, 364 }; 365} 366 367export interface DocumentRecord { 368 $type: "site.standard.document"; 369 title: string; 370 site: string; 371 path: string; 372 textContent: string; 373 publishedAt: string; 374 canonicalUrl?: string; 375 description?: string; 376 coverImage?: BlobObject; 377 tags?: string[]; 378 location?: string; 379} 380 381export interface ListDocumentsResult { 382 uri: string; 383 cid: string; 384 value: DocumentRecord; 385} 386 387export async function listDocuments( 388 agent: Agent, 389 publicationUri?: string, 390): Promise<ListDocumentsResult[]> { 391 const documents: ListDocumentsResult[] = []; 392 let cursor: string | undefined; 393 394 do { 395 const response = await agent.com.atproto.repo.listRecords({ 396 repo: agent.did!, 397 collection: "site.standard.document", 398 limit: 100, 399 cursor, 400 }); 401 402 for (const record of response.data.records) { 403 if (!isDocumentRecord(record.value)) { 404 continue; 405 } 406 407 // If publicationUri is specified, only include documents from that publication 408 if (publicationUri && record.value.site !== publicationUri) { 409 continue; 410 } 411 412 documents.push({ 413 uri: record.uri, 414 cid: record.cid, 415 value: record.value, 416 }); 417 } 418 419 cursor = response.data.cursor; 420 } while (cursor); 421 422 return documents; 423} 424 425export async function createPublication( 426 agent: Agent, 427 options: CreatePublicationOptions, 428): Promise<string> { 429 let icon: BlobObject | undefined; 430 431 if (options.iconPath) { 432 icon = await uploadImage(agent, options.iconPath); 433 } 434 435 const record: Record<string, unknown> = { 436 $type: "site.standard.publication", 437 url: options.url, 438 name: options.name, 439 createdAt: new Date().toISOString(), 440 }; 441 442 if (options.description) { 443 record.description = options.description; 444 } 445 446 if (icon) { 447 record.icon = icon; 448 } 449 450 if (options.showInDiscover !== undefined) { 451 record.preferences = { 452 showInDiscover: options.showInDiscover, 453 }; 454 } 455 456 const response = await agent.com.atproto.repo.createRecord({ 457 repo: agent.did!, 458 collection: "site.standard.publication", 459 record, 460 }); 461 462 return response.data.uri; 463} 464 465export interface GetPublicationResult { 466 uri: string; 467 cid: string; 468 value: PublicationRecord; 469} 470 471export async function getPublication( 472 agent: Agent, 473 publicationUri: string, 474): Promise<GetPublicationResult | null> { 475 const parsed = parseAtUri(publicationUri); 476 if (!parsed) { 477 return null; 478 } 479 480 try { 481 const response = await agent.com.atproto.repo.getRecord({ 482 repo: parsed.did, 483 collection: parsed.collection, 484 rkey: parsed.rkey, 485 }); 486 487 return { 488 uri: publicationUri, 489 cid: response.data.cid!, 490 value: response.data.value as unknown as PublicationRecord, 491 }; 492 } catch { 493 return null; 494 } 495} 496 497export interface UpdatePublicationOptions { 498 url?: string; 499 name?: string; 500 description?: string; 501 iconPath?: string; 502 showInDiscover?: boolean; 503} 504 505export async function updatePublication( 506 agent: Agent, 507 publicationUri: string, 508 options: UpdatePublicationOptions, 509 existingRecord: PublicationRecord, 510): Promise<void> { 511 const parsed = parseAtUri(publicationUri); 512 if (!parsed) { 513 throw new Error(`Invalid publication URI: ${publicationUri}`); 514 } 515 516 // Build updated record, preserving createdAt and $type 517 const record: Record<string, unknown> = { 518 $type: existingRecord.$type, 519 url: options.url ?? existingRecord.url, 520 name: options.name ?? existingRecord.name, 521 createdAt: existingRecord.createdAt, 522 }; 523 524 // Handle description - can be cleared with empty string 525 if (options.description !== undefined) { 526 if (options.description) { 527 record.description = options.description; 528 } 529 // If empty string, don't include description (clears it) 530 } else if (existingRecord.description) { 531 record.description = existingRecord.description; 532 } 533 534 // Handle icon - upload new if provided, otherwise keep existing 535 if (options.iconPath) { 536 const icon = await uploadImage(agent, options.iconPath); 537 if (icon) { 538 record.icon = icon; 539 } 540 } else if (existingRecord.icon) { 541 record.icon = existingRecord.icon; 542 } 543 544 // Handle preferences 545 if (options.showInDiscover !== undefined) { 546 record.preferences = { 547 showInDiscover: options.showInDiscover, 548 }; 549 } else if (existingRecord.preferences) { 550 record.preferences = existingRecord.preferences; 551 } 552 553 await agent.com.atproto.repo.putRecord({ 554 repo: parsed.did, 555 collection: parsed.collection, 556 rkey: parsed.rkey, 557 record, 558 }); 559} 560 561// --- Bluesky Post Creation --- 562 563export interface CreateBlueskyPostOptions { 564 title: string; 565 description?: string; 566 canonicalUrl: string; 567 coverImage?: BlobObject; 568 publishedAt: string; // Used as createdAt for the post 569} 570 571/** 572 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 573 */ 574function countGraphemes(str: string): number { 575 // Use Intl.Segmenter if available, otherwise fallback to spread operator 576 if (typeof Intl !== "undefined" && Intl.Segmenter) { 577 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 578 return [...segmenter.segment(str)].length; 579 } 580 return [...str].length; 581} 582 583/** 584 * Truncate a string to a maximum number of graphemes 585 */ 586function truncateToGraphemes(str: string, maxGraphemes: number): string { 587 if (typeof Intl !== "undefined" && Intl.Segmenter) { 588 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 589 const segments = [...segmenter.segment(str)]; 590 if (segments.length <= maxGraphemes) return str; 591 return `${segments 592 .slice(0, maxGraphemes - 3) 593 .map((s) => s.segment) 594 .join("")}...`; 595 } 596 // Fallback 597 const chars = [...str]; 598 if (chars.length <= maxGraphemes) return str; 599 return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 600} 601 602/** 603 * Create a Bluesky post with external link embed 604 */ 605export async function createBlueskyPost( 606 agent: Agent, 607 options: CreateBlueskyPostOptions, 608): Promise<StrongRef> { 609 const { title, description, canonicalUrl, coverImage, publishedAt } = options; 610 611 // Build post text: title + description + URL 612 // Max 300 graphemes for Bluesky posts 613 const MAX_GRAPHEMES = 300; 614 615 let postText: string; 616 const urlPart = `\n\n${canonicalUrl}`; 617 const urlGraphemes = countGraphemes(urlPart); 618 619 if (description) { 620 // Try: title + description + URL 621 const fullText = `${title}\n\n${description}${urlPart}`; 622 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 623 postText = fullText; 624 } else { 625 // Truncate description to fit 626 const availableForDesc = 627 MAX_GRAPHEMES - 628 countGraphemes(title) - 629 countGraphemes("\n\n") - 630 urlGraphemes - 631 countGraphemes("\n\n"); 632 if (availableForDesc > 10) { 633 const truncatedDesc = truncateToGraphemes( 634 description, 635 availableForDesc, 636 ); 637 postText = `${title}\n\n${truncatedDesc}${urlPart}`; 638 } else { 639 // Just title + URL 640 postText = `${title}${urlPart}`; 641 } 642 } 643 } else { 644 // Just title + URL 645 postText = `${title}${urlPart}`; 646 } 647 648 // Final truncation if still too long (shouldn't happen but safety check) 649 if (countGraphemes(postText) > MAX_GRAPHEMES) { 650 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 651 } 652 653 // Calculate byte indices for the URL facet 654 const encoder = new TextEncoder(); 655 const urlStartInText = postText.lastIndexOf(canonicalUrl); 656 const beforeUrl = postText.substring(0, urlStartInText); 657 const byteStart = encoder.encode(beforeUrl).length; 658 const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 659 660 // Build facets for the URL link 661 const facets = [ 662 { 663 index: { 664 byteStart, 665 byteEnd, 666 }, 667 features: [ 668 { 669 $type: "app.bsky.richtext.facet#link", 670 uri: canonicalUrl, 671 }, 672 ], 673 }, 674 ]; 675 676 // Build external embed 677 const embed: Record<string, unknown> = { 678 $type: "app.bsky.embed.external", 679 external: { 680 uri: canonicalUrl, 681 title: title.substring(0, 500), // Max 500 chars for title 682 description: (description || "").substring(0, 1000), // Max 1000 chars for description 683 }, 684 }; 685 686 // Add thumbnail if coverImage is available 687 if (coverImage) { 688 (embed.external as Record<string, unknown>).thumb = coverImage; 689 } 690 691 // Create the post record 692 const record: Record<string, unknown> = { 693 $type: "app.bsky.feed.post", 694 text: postText, 695 facets, 696 embed, 697 createdAt: new Date(publishedAt).toISOString(), 698 }; 699 700 const response = await agent.com.atproto.repo.createRecord({ 701 repo: agent.did!, 702 collection: "app.bsky.feed.post", 703 record, 704 }); 705 706 return { 707 uri: response.data.uri, 708 cid: response.data.cid, 709 }; 710} 711 712/** 713 * Add bskyPostRef to an existing document record 714 */ 715export async function addBskyPostRefToDocument( 716 agent: Agent, 717 documentAtUri: string, 718 bskyPostRef: StrongRef, 719): Promise<void> { 720 const parsed = parseAtUri(documentAtUri); 721 if (!parsed) { 722 throw new Error(`Invalid document URI: ${documentAtUri}`); 723 } 724 725 // Fetch existing record 726 const existingRecord = await agent.com.atproto.repo.getRecord({ 727 repo: parsed.did, 728 collection: parsed.collection, 729 rkey: parsed.rkey, 730 }); 731 732 // Add bskyPostRef to the record 733 const updatedRecord = { 734 ...(existingRecord.data.value as Record<string, unknown>), 735 bskyPostRef, 736 }; 737 738 // Update the record 739 await agent.com.atproto.repo.putRecord({ 740 repo: parsed.did, 741 collection: parsed.collection, 742 rkey: parsed.rkey, 743 record: updatedRecord, 744 }); 745}