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