A CLI for publishing standard.site documents to ATProto
at sync-update 756 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, resolvePostPath } 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 postPath = resolvePostPath( 249 post, 250 config.pathPrefix, 251 config.pathTemplate, 252 ); 253 const publishDate = new Date(post.frontmatter.publishDate); 254 255 // Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body 256 let textContent: string | null = null; 257 if ( 258 config.publishContent && 259 config.textContentField && 260 post.rawFrontmatter?.[config.textContentField] 261 ) { 262 textContent = String(post.rawFrontmatter[config.textContentField]); 263 } else if (config.publishContent) { 264 textContent = stripMarkdownForText(post.content); 265 } 266 267 const record: Record<string, unknown> = { 268 $type: "site.standard.document", 269 title: post.frontmatter.title, 270 site: config.publicationUri, 271 path: postPath, 272 textContent: textContent?.slice(0, 10000), 273 publishedAt: publishDate.toISOString(), 274 canonicalUrl: `${config.siteUrl}${postPath}`, 275 }; 276 277 if (post.frontmatter.description) { 278 record.description = post.frontmatter.description; 279 } 280 281 if (coverImage) { 282 record.coverImage = coverImage; 283 } 284 285 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 286 record.tags = post.frontmatter.tags; 287 } 288 289 const response = await agent.com.atproto.repo.createRecord({ 290 repo: agent.did!, 291 collection: "site.standard.document", 292 record, 293 }); 294 295 return response.data.uri; 296} 297 298export async function updateDocument( 299 agent: Agent, 300 post: BlogPost, 301 atUri: string, 302 config: PublisherConfig, 303 coverImage?: BlobObject, 304): Promise<void> { 305 // Parse the atUri to get the collection and rkey 306 // Format: at://did:plc:xxx/collection/rkey 307 const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 308 if (!uriMatch) { 309 throw new Error(`Invalid atUri format: ${atUri}`); 310 } 311 312 const [, , collection, rkey] = uriMatch; 313 314 const postPath = resolvePostPath( 315 post, 316 config.pathPrefix, 317 config.pathTemplate, 318 ); 319 const publishDate = new Date(post.frontmatter.publishDate); 320 321 // Determine textContent (if enabled): use configured field from frontmatter, or fallback to markdown body 322 let textContent: string | null = null; 323 if ( 324 config.publishContent && 325 config.textContentField && 326 post.rawFrontmatter?.[config.textContentField] 327 ) { 328 textContent = String(post.rawFrontmatter[config.textContentField]); 329 } else if (config.publishContent) { 330 textContent = stripMarkdownForText(post.content); 331 } 332 333 // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 334 const existingResponse = await agent.com.atproto.repo.getRecord({ 335 repo: agent.did!, 336 collection: collection!, 337 rkey: rkey!, 338 }); 339 const existingRecord = existingResponse.data.value as Record<string, unknown>; 340 341 const record: Record<string, unknown> = { 342 ...existingRecord, 343 $type: "site.standard.document", 344 title: post.frontmatter.title, 345 site: config.publicationUri, 346 path: postPath, 347 textContent: textContent?.slice(0, 10000), 348 publishedAt: publishDate.toISOString(), 349 canonicalUrl: `${config.siteUrl}${postPath}`, 350 }; 351 352 if (post.frontmatter.description) { 353 record.description = post.frontmatter.description; 354 } 355 356 if (coverImage) { 357 record.coverImage = coverImage; 358 } 359 360 if (post.frontmatter.tags && post.frontmatter.tags.length > 0) { 361 record.tags = post.frontmatter.tags; 362 } 363 364 await agent.com.atproto.repo.putRecord({ 365 repo: agent.did!, 366 collection: collection!, 367 rkey: rkey!, 368 record, 369 }); 370} 371 372export async function deleteDocument(agent: Agent, atUri: string): Promise<void> { 373 const parsed = parseAtUri(atUri); 374 if (!parsed) { 375 throw new Error(`Invalid atUri format: ${atUri}`); 376 } 377 378 await agent.com.atproto.repo.deleteRecord({ 379 repo: agent.did!, 380 collection: parsed.collection, 381 rkey: parsed.rkey, 382 }); 383} 384 385export function parseAtUri( 386 atUri: string, 387): { did: string; collection: string; rkey: string } | null { 388 const match = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); 389 if (!match) return null; 390 return { 391 did: match[1]!, 392 collection: match[2]!, 393 rkey: match[3]!, 394 }; 395} 396 397export interface DocumentRecord { 398 $type: "site.standard.document"; 399 title: string; 400 site: string; 401 path: string; 402 textContent?: string; 403 publishedAt: string; 404 canonicalUrl?: string; 405 description?: string; 406 coverImage?: BlobObject; 407 tags?: string[]; 408 location?: string; 409} 410 411export interface ListDocumentsResult { 412 uri: string; 413 cid: string; 414 value: DocumentRecord; 415} 416 417export async function listDocuments( 418 agent: Agent, 419 publicationUri?: string, 420): Promise<ListDocumentsResult[]> { 421 const documents: ListDocumentsResult[] = []; 422 let cursor: string | undefined; 423 424 do { 425 const response = await agent.com.atproto.repo.listRecords({ 426 repo: agent.did!, 427 collection: "site.standard.document", 428 limit: 100, 429 cursor, 430 }); 431 432 for (const record of response.data.records) { 433 if (!isDocumentRecord(record.value)) { 434 continue; 435 } 436 437 // If publicationUri is specified, only include documents from that publication 438 if (publicationUri && record.value.site !== publicationUri) { 439 continue; 440 } 441 442 documents.push({ 443 uri: record.uri, 444 cid: record.cid, 445 value: record.value, 446 }); 447 } 448 449 cursor = response.data.cursor; 450 } while (cursor); 451 452 return documents; 453} 454 455export async function createPublication( 456 agent: Agent, 457 options: CreatePublicationOptions, 458): Promise<string> { 459 let icon: BlobObject | undefined; 460 461 if (options.iconPath) { 462 icon = await uploadImage(agent, options.iconPath); 463 } 464 465 const record: Record<string, unknown> = { 466 $type: "site.standard.publication", 467 url: options.url, 468 name: options.name, 469 createdAt: new Date().toISOString(), 470 }; 471 472 if (options.description) { 473 record.description = options.description; 474 } 475 476 if (icon) { 477 record.icon = icon; 478 } 479 480 if (options.showInDiscover !== undefined) { 481 record.preferences = { 482 showInDiscover: options.showInDiscover, 483 }; 484 } 485 486 const response = await agent.com.atproto.repo.createRecord({ 487 repo: agent.did!, 488 collection: "site.standard.publication", 489 record, 490 }); 491 492 return response.data.uri; 493} 494 495export interface GetPublicationResult { 496 uri: string; 497 cid: string; 498 value: PublicationRecord; 499} 500 501export async function getPublication( 502 agent: Agent, 503 publicationUri: string, 504): Promise<GetPublicationResult | null> { 505 const parsed = parseAtUri(publicationUri); 506 if (!parsed) { 507 return null; 508 } 509 510 try { 511 const response = await agent.com.atproto.repo.getRecord({ 512 repo: parsed.did, 513 collection: parsed.collection, 514 rkey: parsed.rkey, 515 }); 516 517 return { 518 uri: publicationUri, 519 cid: response.data.cid!, 520 value: response.data.value as unknown as PublicationRecord, 521 }; 522 } catch { 523 return null; 524 } 525} 526 527export interface UpdatePublicationOptions { 528 url?: string; 529 name?: string; 530 description?: string; 531 iconPath?: string; 532 showInDiscover?: boolean; 533} 534 535export async function updatePublication( 536 agent: Agent, 537 publicationUri: string, 538 options: UpdatePublicationOptions, 539 existingRecord: PublicationRecord, 540): Promise<void> { 541 const parsed = parseAtUri(publicationUri); 542 if (!parsed) { 543 throw new Error(`Invalid publication URI: ${publicationUri}`); 544 } 545 546 // Build updated record, preserving createdAt and $type 547 const record: Record<string, unknown> = { 548 $type: existingRecord.$type, 549 url: options.url ?? existingRecord.url, 550 name: options.name ?? existingRecord.name, 551 createdAt: existingRecord.createdAt, 552 }; 553 554 // Handle description - can be cleared with empty string 555 if (options.description !== undefined) { 556 if (options.description) { 557 record.description = options.description; 558 } 559 // If empty string, don't include description (clears it) 560 } else if (existingRecord.description) { 561 record.description = existingRecord.description; 562 } 563 564 // Handle icon - upload new if provided, otherwise keep existing 565 if (options.iconPath) { 566 const icon = await uploadImage(agent, options.iconPath); 567 if (icon) { 568 record.icon = icon; 569 } 570 } else if (existingRecord.icon) { 571 record.icon = existingRecord.icon; 572 } 573 574 // Handle preferences 575 if (options.showInDiscover !== undefined) { 576 record.preferences = { 577 showInDiscover: options.showInDiscover, 578 }; 579 } else if (existingRecord.preferences) { 580 record.preferences = existingRecord.preferences; 581 } 582 583 await agent.com.atproto.repo.putRecord({ 584 repo: parsed.did, 585 collection: parsed.collection, 586 rkey: parsed.rkey, 587 record, 588 }); 589} 590 591// --- Bluesky Post Creation --- 592 593export interface CreateBlueskyPostOptions { 594 title: string; 595 description?: string; 596 bskyPost?: string; 597 canonicalUrl: string; 598 coverImage?: BlobObject; 599 publishedAt: string; // Used as createdAt for the post 600} 601 602/** 603 * Count graphemes in a string (for Bluesky's 300 grapheme limit) 604 */ 605function countGraphemes(str: string): number { 606 // Use Intl.Segmenter if available, otherwise fallback to spread operator 607 if (typeof Intl !== "undefined" && Intl.Segmenter) { 608 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 609 return [...segmenter.segment(str)].length; 610 } 611 return [...str].length; 612} 613 614/** 615 * Truncate a string to a maximum number of graphemes 616 */ 617function truncateToGraphemes(str: string, maxGraphemes: number): string { 618 if (typeof Intl !== "undefined" && Intl.Segmenter) { 619 const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" }); 620 const segments = [...segmenter.segment(str)]; 621 if (segments.length <= maxGraphemes) return str; 622 return `${segments 623 .slice(0, maxGraphemes - 3) 624 .map((s) => s.segment) 625 .join("")}...`; 626 } 627 // Fallback 628 const chars = [...str]; 629 if (chars.length <= maxGraphemes) return str; 630 return `${chars.slice(0, maxGraphemes - 3).join("")}...`; 631} 632 633/** 634 * Create a Bluesky post with external link embed 635 */ 636export async function createBlueskyPost( 637 agent: Agent, 638 options: CreateBlueskyPostOptions, 639): Promise<StrongRef> { 640 const { 641 title, 642 description, 643 bskyPost, 644 canonicalUrl, 645 coverImage, 646 publishedAt, 647 } = options; 648 649 // Build post text: title + description 650 // Max 300 graphemes for Bluesky posts 651 const MAX_GRAPHEMES = 300; 652 653 let postText: string; 654 655 if (bskyPost) { 656 // Custom bsky post overrides any default behavior 657 postText = bskyPost; 658 } else if (description) { 659 // Try: title + description 660 const fullText = `${title}\n\n${description}`; 661 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 662 postText = fullText; 663 } else { 664 // Truncate description to fit 665 const availableForDesc = 666 MAX_GRAPHEMES - countGraphemes(title) - countGraphemes("\n\n"); 667 if (availableForDesc > 10) { 668 const truncatedDesc = truncateToGraphemes( 669 description, 670 availableForDesc, 671 ); 672 postText = `${title}\n\n${truncatedDesc}`; 673 } else { 674 // Just title 675 postText = `${title}`; 676 } 677 } 678 } else { 679 // Just title 680 postText = `${title}`; 681 } 682 683 // Final truncation in case title or bskyPost are longer than expected 684 if (countGraphemes(postText) > MAX_GRAPHEMES) { 685 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 686 } 687 688 // Build external embed 689 const embed: Record<string, unknown> = { 690 $type: "app.bsky.embed.external", 691 external: { 692 uri: canonicalUrl, 693 title: title.substring(0, 500), // Max 500 chars for title 694 description: (description || "").substring(0, 1000), // Max 1000 chars for description 695 }, 696 }; 697 698 // Add thumbnail if coverImage is available 699 if (coverImage) { 700 (embed.external as Record<string, unknown>).thumb = coverImage; 701 } 702 703 // Create the post record 704 const record: Record<string, unknown> = { 705 $type: "app.bsky.feed.post", 706 text: postText, 707 embed, 708 createdAt: new Date(publishedAt).toISOString(), 709 }; 710 711 const response = await agent.com.atproto.repo.createRecord({ 712 repo: agent.did!, 713 collection: "app.bsky.feed.post", 714 record, 715 }); 716 717 return { 718 uri: response.data.uri, 719 cid: response.data.cid, 720 }; 721} 722 723/** 724 * Add bskyPostRef to an existing document record 725 */ 726export async function addBskyPostRefToDocument( 727 agent: Agent, 728 documentAtUri: string, 729 bskyPostRef: StrongRef, 730): Promise<void> { 731 const parsed = parseAtUri(documentAtUri); 732 if (!parsed) { 733 throw new Error(`Invalid document URI: ${documentAtUri}`); 734 } 735 736 // Fetch existing record 737 const existingRecord = await agent.com.atproto.repo.getRecord({ 738 repo: parsed.did, 739 collection: parsed.collection, 740 rkey: parsed.rkey, 741 }); 742 743 // Add bskyPostRef to the record 744 const updatedRecord = { 745 ...(existingRecord.data.value as Record<string, unknown>), 746 bskyPostRef, 747 }; 748 749 // Update the record 750 await agent.com.atproto.repo.putRecord({ 751 repo: parsed.did, 752 collection: parsed.collection, 753 rkey: parsed.rkey, 754 record: updatedRecord, 755 }); 756}