A CLI for publishing standard.site documents to ATProto
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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