forked from
stevedylan.dev/sequoia
A CLI for publishing standard.site documents to ATProto
1import * as fs from "node:fs/promises";
2import { command, flag } from "cmd-ts";
3import { select, spinner, log } from "@clack/prompts";
4import * as path from "node:path";
5import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6import {
7 loadCredentials,
8 listAllCredentials,
9 getCredentials,
10} from "../lib/credentials";
11import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
12import {
13 createAgent,
14 createDocument,
15 updateDocument,
16 uploadImage,
17 resolveImagePath,
18 createBlueskyPost,
19 addBskyPostRefToDocument,
20 deleteRecord,
21 listDocuments,
22} from "../lib/atproto";
23import {
24 scanContentDirectory,
25 getContentHash,
26 updateFrontmatterWithAtUri,
27 resolvePostPath,
28} from "../lib/markdown";
29import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
30import { exitOnCancel } from "../lib/prompts";
31import {
32 createNote,
33 updateNote,
34 deleteNote,
35 findPostsWithStaleLinks,
36 type NoteOptions,
37} from "../extensions/remanso";
38import { fileExists } from "../lib/utils";
39
40export const publishCommand = command({
41 name: "publish",
42 description: "Publish content to ATProto",
43 args: {
44 force: flag({
45 long: "force",
46 short: "f",
47 description: "Force publish all posts, ignoring change detection",
48 }),
49 dryRun: flag({
50 long: "dry-run",
51 short: "n",
52 description: "Preview what would be published without making changes",
53 }),
54 verbose: flag({
55 long: "verbose",
56 short: "v",
57 description: "Show more information",
58 }),
59 },
60 handler: async ({ force, dryRun, verbose }) => {
61 // Load config
62 const configPath = await findConfig();
63 if (!configPath) {
64 log.error("No publisher.config.ts found. Run 'publisher init' first.");
65 process.exit(1);
66 }
67
68 const config = await loadConfig(configPath);
69 const configDir = path.dirname(configPath);
70
71 log.info(`Site: ${config.siteUrl}`);
72 log.info(`Content directory: ${config.contentDir}`);
73
74 // Load credentials
75 let credentials = await loadCredentials(config.identity);
76
77 // If no credentials resolved, check if we need to prompt for identity selection
78 if (!credentials) {
79 const identities = await listAllCredentials();
80 if (identities.length === 0) {
81 log.error(
82 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
83 );
84 log.info(
85 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
86 );
87 process.exit(1);
88 }
89
90 // Build labels with handles for OAuth sessions
91 const options = await Promise.all(
92 identities.map(async (cred) => {
93 if (cred.type === "oauth") {
94 const handle = await getOAuthHandle(cred.id);
95 return {
96 value: cred.id,
97 label: `${handle || cred.id} (OAuth)`,
98 };
99 }
100 return {
101 value: cred.id,
102 label: `${cred.id} (App Password)`,
103 };
104 }),
105 );
106
107 // Multiple identities exist but none selected - prompt user
108 log.info("Multiple identities found. Select one to use:");
109 const selected = exitOnCancel(
110 await select({
111 message: "Identity:",
112 options,
113 }),
114 );
115
116 // Load the selected credentials
117 const selectedCred = identities.find((c) => c.id === selected);
118 if (selectedCred?.type === "oauth") {
119 const session = await getOAuthSession(selected);
120 if (session) {
121 const handle = await getOAuthHandle(selected);
122 credentials = {
123 type: "oauth",
124 did: selected,
125 handle: handle || selected,
126 };
127 }
128 } else {
129 credentials = await getCredentials(selected);
130 }
131
132 if (!credentials) {
133 log.error("Failed to load selected credentials.");
134 process.exit(1);
135 }
136
137 const displayId =
138 credentials.type === "oauth"
139 ? credentials.handle || credentials.did
140 : credentials.identifier;
141 log.info(
142 `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
143 );
144 }
145
146 // Resolve content directory
147 const contentDir = path.isAbsolute(config.contentDir)
148 ? config.contentDir
149 : path.join(configDir, config.contentDir);
150
151 const imagesDir = config.imagesDir
152 ? path.isAbsolute(config.imagesDir)
153 ? config.imagesDir
154 : path.join(configDir, config.imagesDir)
155 : undefined;
156
157 // Load state
158 const state = await loadState(configDir);
159
160 // Scan for posts
161 const s = spinner();
162 s.start("Scanning for posts...");
163 const posts = await scanContentDirectory(contentDir, {
164 frontmatterMapping: config.frontmatter,
165 ignorePatterns: config.ignore,
166 slugField: config.frontmatter?.slugField,
167 removeIndexFromSlug: config.removeIndexFromSlug,
168 stripDatePrefix: config.stripDatePrefix,
169 });
170 s.stop(`Found ${posts.length} posts`);
171
172 // Detect deleted files: state entries whose local files no longer exist
173 const scannedPaths = new Set(
174 posts.map((p) => path.relative(configDir, p.filePath)),
175 );
176 const deletedEntries: Array<{ filePath: string; atUri: string }> = [];
177
178 for (const [filePath, postState] of Object.entries(state.posts)) {
179 if (!scannedPaths.has(filePath) && postState.atUri) {
180 // Check if the file truly doesn't exist (not just excluded by ignore patterns)
181 const absolutePath = path.resolve(configDir, filePath);
182 if (!(await fileExists(absolutePath))) {
183 deletedEntries.push({ filePath, atUri: postState.atUri });
184 }
185 }
186 }
187
188 // Detect unmatched PDS records: exist on PDS but have no matching local file
189 const unmatchedEntries: Array<{ atUri: string; title: string }> = [];
190
191 // Shared agent — created lazily, reused across deletion and publishing
192 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
193 async function getAgent(): Promise<
194 Awaited<ReturnType<typeof createAgent>>
195 > {
196 if (agent) return agent;
197
198 if (!credentials) {
199 throw new Error("credentials not found");
200 }
201
202 const connectingTo =
203 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
204 s.start(`Connecting as ${connectingTo}...`);
205 try {
206 agent = await createAgent(credentials);
207 s.stop(`Logged in as ${agent.did}`);
208 return agent;
209 } catch (error) {
210 s.stop("Failed to login");
211 log.error(`Failed to login: ${error}`);
212 process.exit(1);
213 }
214 }
215
216 // Determine which posts need publishing
217 const postsToPublish: Array<{
218 post: BlogPost;
219 action: "create" | "update";
220 reason: "content changed" | "forced" | "new post" | "missing state";
221 }> = [];
222 const draftPosts: BlogPost[] = [];
223
224 for (const post of posts) {
225 // Skip draft posts
226 if (post.frontmatter.draft) {
227 draftPosts.push(post);
228 continue;
229 }
230
231 const contentHash = await getContentHash(post.rawContent);
232 const relativeFilePath = path.relative(configDir, post.filePath);
233 const postState = state.posts[relativeFilePath];
234
235 if (force) {
236 postsToPublish.push({
237 post,
238 action: post.frontmatter.atUri ? "update" : "create",
239 reason: "forced",
240 });
241 } else if (!postState) {
242 postsToPublish.push({
243 post,
244 action: post.frontmatter.atUri ? "update" : "create",
245 reason: post.frontmatter.atUri ? "missing state" : "new post",
246 });
247 } else if (postState.contentHash !== contentHash) {
248 // Changed post
249 postsToPublish.push({
250 post,
251 action: post.frontmatter.atUri ? "update" : "create",
252 reason: "content changed",
253 });
254 }
255 }
256
257 if (draftPosts.length > 0) {
258 log.info(
259 `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`,
260 );
261 }
262
263 // Fetch PDS records and detect unmatched documents
264 async function fetchUnmatchedRecords() {
265 const ag = await getAgent();
266 s.start("Fetching documents from PDS...");
267 const pdsDocuments = await listDocuments(ag, config.publicationUri);
268 s.stop(`Found ${pdsDocuments.length} documents on PDS`);
269
270 const pathPrefix = config.pathPrefix || "/posts";
271 const postsByPath = new Map<string, BlogPost>();
272 for (const post of posts) {
273 postsByPath.set(`${pathPrefix}/${post.slug}`, post);
274 }
275 const deletedAtUris = new Set(deletedEntries.map((e) => e.atUri));
276 for (const doc of pdsDocuments) {
277 if (!postsByPath.has(doc.value.path) && !deletedAtUris.has(doc.uri)) {
278 unmatchedEntries.push({
279 atUri: doc.uri,
280 title: doc.value.title || doc.value.path,
281 });
282 }
283 }
284 }
285
286 if (postsToPublish.length === 0 && deletedEntries.length === 0) {
287 await fetchUnmatchedRecords();
288
289 if (unmatchedEntries.length === 0) {
290 log.success("All posts are up to date. Nothing to publish.");
291 return;
292 }
293 }
294
295 // Bluesky posting configuration
296 const blueskyEnabled = config.bluesky?.enabled ?? false;
297 const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
298 const cutoffDate = new Date();
299 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
300
301 if (postsToPublish.length > 0) {
302 log.info(`\n${postsToPublish.length} posts to publish:\n`);
303
304 for (const { post, action, reason } of postsToPublish) {
305 const icon = action === "create" ? "+" : "~";
306 const relativeFilePath = path.relative(configDir, post.filePath);
307 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
308
309 let bskyNote = "";
310 if (blueskyEnabled) {
311 if (existingBskyPostRef) {
312 bskyNote = " [bsky: exists]";
313 } else {
314 const publishDate = new Date(post.frontmatter.publishDate);
315 if (publishDate < cutoffDate) {
316 bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
317 } else {
318 bskyNote = " [bsky: will post]";
319 }
320 }
321 }
322
323 let postUrl = "";
324 if (verbose) {
325 const postPath = resolvePostPath(
326 post,
327 config.pathPrefix,
328 config.pathTemplate,
329 );
330 postUrl = `\n ${config.siteUrl}${postPath}`;
331 }
332 log.message(
333 ` ${icon} ${post.filePath} (${reason})${bskyNote}${postUrl}`,
334 );
335 }
336 }
337
338 if (deletedEntries.length > 0) {
339 log.info(
340 `\n${deletedEntries.length} deleted local files to remove from PDS:\n`,
341 );
342 for (const { filePath } of deletedEntries) {
343 log.message(` - ${filePath}`);
344 }
345 }
346
347 if (unmatchedEntries.length > 0) {
348 log.info(
349 `\n${unmatchedEntries.length} unmatched PDS records to delete:\n`,
350 );
351 for (const { title } of unmatchedEntries) {
352 log.message(` - ${title}`);
353 }
354 }
355
356 if (dryRun) {
357 if (blueskyEnabled) {
358 log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
359 }
360 log.info("\nDry run complete. No changes made.");
361 return;
362 }
363
364 // Ensure agent is connected
365 await getAgent();
366
367 if (!agent) {
368 throw new Error("agent is not connected");
369 }
370
371 // Fetch PDS records to detect unmatched documents (if not already done)
372 if (unmatchedEntries.length === 0) {
373 await fetchUnmatchedRecords();
374 }
375
376 // Publish posts
377 let publishedCount = 0;
378 let updatedCount = 0;
379 let errorCount = 0;
380 let bskyPostCount = 0;
381
382 const context: NoteOptions = {
383 contentDir,
384 imagesDir,
385 allPosts: posts,
386 };
387
388 // Pass 1: Create/update document records and collect note queue
389 const noteQueue: Array<{
390 post: BlogPost;
391 action: "create" | "update";
392 atUri: string;
393 }> = [];
394
395 for (const { post, action } of postsToPublish) {
396 const trimmedContent = post.content.trim();
397 const titleMatch = trimmedContent.match(/^# (.+)$/m);
398 const title = titleMatch ? titleMatch[1] : post.frontmatter.title;
399 s.start(`Publishing: ${title}`);
400
401 // Init publish date
402 if (!post.frontmatter.publishDate) {
403 const [publishDate] = new Date().toISOString().split("T");
404 post.frontmatter.publishDate = publishDate!;
405 }
406
407 try {
408 // Handle cover image upload
409 let coverImage: BlobObject | undefined;
410 if (post.frontmatter.ogImage) {
411 const imagePath = await resolveImagePath(
412 post.frontmatter.ogImage,
413 imagesDir,
414 contentDir,
415 );
416
417 if (imagePath) {
418 log.info(` Uploading cover image: ${path.basename(imagePath)}`);
419 coverImage = await uploadImage(agent, imagePath);
420 if (coverImage) {
421 log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
422 }
423 } else {
424 log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
425 }
426 }
427
428 // Track atUri, content for state saving, and bskyPostRef
429 let atUri: string;
430 let contentForHash: string;
431 let bskyPostRef: StrongRef | undefined;
432 const relativeFilePath = path.relative(configDir, post.filePath);
433
434 // Check if bskyPostRef already exists in state
435 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
436
437 if (action === "create") {
438 atUri = await createDocument(agent, post, config, coverImage);
439 post.frontmatter.atUri = atUri;
440 s.stop(`Created: ${atUri}`);
441
442 // Update frontmatter with atUri
443 const updatedContent = updateFrontmatterWithAtUri(
444 post.rawContent,
445 atUri,
446 );
447 await fs.writeFile(post.filePath, updatedContent);
448 log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
449
450 // Use updated content (with atUri) for hash so next run sees matching hash
451 contentForHash = updatedContent;
452 publishedCount++;
453 } else {
454 atUri = post.frontmatter.atUri!;
455 await updateDocument(agent, post, atUri, config, coverImage);
456 s.stop(`Updated: ${atUri}`);
457
458 // For updates, rawContent already has atUri
459 contentForHash = post.rawContent;
460 updatedCount++;
461 }
462
463 // Create Bluesky post if enabled and conditions are met
464 if (blueskyEnabled) {
465 if (existingBskyPostRef) {
466 log.info(` Bluesky post already exists, skipping`);
467 bskyPostRef = existingBskyPostRef;
468 } else {
469 const publishDate = new Date(post.frontmatter.publishDate);
470
471 if (publishDate < cutoffDate) {
472 log.info(
473 ` Post is older than ${maxAgeDays} days, skipping Bluesky post`,
474 );
475 } else {
476 // Create Bluesky post
477 try {
478 const canonicalUrl = `${config.siteUrl}${resolvePostPath(post, config.pathPrefix, config.pathTemplate)}`;
479
480 bskyPostRef = await createBlueskyPost(agent, {
481 title: post.frontmatter.title,
482 description: post.frontmatter.description,
483 canonicalUrl,
484 coverImage,
485 publishedAt: post.frontmatter.publishDate,
486 });
487
488 // Update document record with bskyPostRef
489 await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
490 log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
491 bskyPostCount++;
492 } catch (bskyError) {
493 const errorMsg =
494 bskyError instanceof Error
495 ? bskyError.message
496 : String(bskyError);
497 log.warn(` Failed to create Bluesky post: ${errorMsg}`);
498 }
499 }
500 }
501 }
502
503 // Update state (use relative path from config directory)
504 const contentHash = await getContentHash(contentForHash);
505 state.posts[relativeFilePath] = {
506 contentHash,
507 atUri,
508 lastPublished: new Date().toISOString(),
509 slug: post.slug,
510 bskyPostRef,
511 };
512
513 noteQueue.push({ post, action, atUri });
514 } catch (error) {
515 const errorMessage =
516 error instanceof Error ? error.message : String(error);
517 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
518 log.error(` ${errorMessage}`);
519 errorCount++;
520 }
521 }
522
523 // Pass 2: Create/update Remanso notes (atUris are now available for link resolution)
524 for (const { post, action, atUri } of noteQueue) {
525 try {
526 if (action === "create") {
527 await createNote(agent, post, atUri, context);
528 } else {
529 await updateNote(agent, post, atUri, context);
530 }
531 } catch (error) {
532 log.warn(
533 `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`,
534 );
535 }
536 }
537
538 // Re-process already-published posts with stale links to newly created posts
539 const newlyCreatedSlugs = noteQueue
540 .filter((r) => r.action === "create")
541 .map((r) => r.post.slug);
542
543 if (newlyCreatedSlugs.length > 0) {
544 const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath));
545 const stalePosts = findPostsWithStaleLinks(
546 posts,
547 newlyCreatedSlugs,
548 batchFilePaths,
549 );
550
551 for (const stalePost of stalePosts) {
552 try {
553 s.start(`Updating links in: ${stalePost.frontmatter.title}`);
554 await updateNote(
555 agent,
556 stalePost,
557 stalePost.frontmatter.atUri!,
558 context,
559 );
560 s.stop(`Updated links: ${stalePost.frontmatter.title}`);
561 } catch (error) {
562 s.stop(`Failed to update links: ${stalePost.frontmatter.title}`);
563 log.warn(
564 ` ${error instanceof Error ? error.message : String(error)}`,
565 );
566 }
567 }
568 }
569
570 // Delete records for removed files
571 let deletedCount = 0;
572 for (const { filePath, atUri } of deletedEntries) {
573 try {
574 const ag = await getAgent();
575 s.start(`Deleting: ${filePath}`);
576 await deleteRecord(ag, atUri);
577
578 // Try to delete the corresponding Remanso note
579 try {
580 const noteAtUri = atUri.replace(
581 "site.standard.document",
582 "space.remanso.note",
583 );
584 await deleteNote(ag, noteAtUri);
585 } catch {
586 // Note may not exist, ignore
587 }
588
589 delete state.posts[filePath];
590 s.stop(`Deleted: ${filePath}`);
591 deletedCount++;
592 } catch (error) {
593 s.stop(`Failed to delete: ${filePath}`);
594 log.warn(` ${error instanceof Error ? error.message : String(error)}`);
595 }
596 }
597
598 // Delete unmatched PDS records (exist on PDS but no matching local file)
599 let unmatchedDeletedCount = 0;
600 for (const { atUri, title } of unmatchedEntries) {
601 try {
602 const ag = await getAgent();
603 s.start(`Deleting unmatched: ${title}`);
604 await deleteRecord(ag, atUri);
605
606 // Try to delete the corresponding Remanso note
607 try {
608 const noteAtUri = atUri.replace(
609 "site.standard.document",
610 "space.remanso.note",
611 );
612 await deleteNote(ag, noteAtUri);
613 } catch {
614 // Note may not exist, ignore
615 }
616
617 s.stop(`Deleted unmatched: ${title}`);
618 unmatchedDeletedCount++;
619 } catch (error) {
620 s.stop(`Failed to delete: ${title}`);
621 log.warn(` ${error instanceof Error ? error.message : String(error)}`);
622 }
623 }
624
625 // Save state
626 await saveState(configDir, state);
627
628 // Summary
629 log.message("\n---");
630 const totalDeleted = deletedCount + unmatchedDeletedCount;
631 if (totalDeleted > 0) {
632 log.info(`Deleted: ${totalDeleted}`);
633 }
634 log.info(`Published: ${publishedCount}`);
635 log.info(`Updated: ${updatedCount}`);
636 if (bskyPostCount > 0) {
637 log.info(`Bluesky posts: ${bskyPostCount}`);
638 }
639 if (errorCount > 0) {
640 log.warn(`Errors: ${errorCount}`);
641 }
642 },
643});