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