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 { 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}