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