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