forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
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}