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} from "../lib/atproto";
21import {
22 scanContentDirectory,
23 getContentHash,
24 updateFrontmatterWithAtUri,
25} from "../lib/markdown";
26import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
27import { exitOnCancel } from "../lib/prompts";
28import { createNote, updateNote, findPostsWithStaleLinks, type NoteOptions } from "../extensions/litenote"
29
30export const publishCommand = command({
31 name: "publish",
32 description: "Publish content to ATProto",
33 args: {
34 force: flag({
35 long: "force",
36 short: "f",
37 description: "Force publish all posts, ignoring change detection",
38 }),
39 dryRun: flag({
40 long: "dry-run",
41 short: "n",
42 description: "Preview what would be published without making changes",
43 }),
44 },
45 handler: async ({ force, dryRun }) => {
46 // Load config
47 const configPath = await findConfig();
48 if (!configPath) {
49 log.error("No publisher.config.ts found. Run 'publisher init' first.");
50 process.exit(1);
51 }
52
53 const config = await loadConfig(configPath);
54 const configDir = path.dirname(configPath);
55
56 log.info(`Site: ${config.siteUrl}`);
57 log.info(`Content directory: ${config.contentDir}`);
58
59 // Load credentials
60 let credentials = await loadCredentials(config.identity);
61
62 // If no credentials resolved, check if we need to prompt for identity selection
63 if (!credentials) {
64 const identities = await listAllCredentials();
65 if (identities.length === 0) {
66 log.error(
67 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
68 );
69 log.info(
70 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
71 );
72 process.exit(1);
73 }
74
75 // Build labels with handles for OAuth sessions
76 const options = await Promise.all(
77 identities.map(async (cred) => {
78 if (cred.type === "oauth") {
79 const handle = await getOAuthHandle(cred.id);
80 return {
81 value: cred.id,
82 label: `${handle || cred.id} (OAuth)`,
83 };
84 }
85 return {
86 value: cred.id,
87 label: `${cred.id} (App Password)`,
88 };
89 }),
90 );
91
92 // Multiple identities exist but none selected - prompt user
93 log.info("Multiple identities found. Select one to use:");
94 const selected = exitOnCancel(
95 await select({
96 message: "Identity:",
97 options,
98 }),
99 );
100
101 // Load the selected credentials
102 const selectedCred = identities.find((c) => c.id === selected);
103 if (selectedCred?.type === "oauth") {
104 const session = await getOAuthSession(selected);
105 if (session) {
106 const handle = await getOAuthHandle(selected);
107 credentials = {
108 type: "oauth",
109 did: selected,
110 handle: handle || selected,
111 };
112 }
113 } else {
114 credentials = await getCredentials(selected);
115 }
116
117 if (!credentials) {
118 log.error("Failed to load selected credentials.");
119 process.exit(1);
120 }
121
122 const displayId =
123 credentials.type === "oauth"
124 ? credentials.handle || credentials.did
125 : credentials.identifier;
126 log.info(
127 `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
128 );
129 }
130
131 // Resolve content directory
132 const contentDir = path.isAbsolute(config.contentDir)
133 ? config.contentDir
134 : path.join(configDir, config.contentDir);
135
136 const imagesDir = config.imagesDir
137 ? path.isAbsolute(config.imagesDir)
138 ? config.imagesDir
139 : path.join(configDir, config.imagesDir)
140 : undefined;
141
142 // Load state
143 const state = await loadState(configDir);
144
145 // Scan for posts
146 const s = spinner();
147 s.start("Scanning for posts...");
148 const posts = await scanContentDirectory(contentDir, {
149 frontmatterMapping: config.frontmatter,
150 ignorePatterns: config.ignore,
151 slugField: config.frontmatter?.slugField,
152 removeIndexFromSlug: config.removeIndexFromSlug,
153 stripDatePrefix: config.stripDatePrefix,
154 });
155 s.stop(`Found ${posts.length} posts`);
156
157 // Determine which posts need publishing
158 const postsToPublish: Array<{
159 post: BlogPost;
160 action: "create" | "update";
161 reason: "content changed" | "forced" | "new post" | "missing state";
162 }> = [];
163 const draftPosts: BlogPost[] = [];
164
165 for (const post of posts) {
166 // Skip draft posts
167 if (post.frontmatter.draft) {
168 draftPosts.push(post);
169 continue;
170 }
171
172 const contentHash = await getContentHash(post.rawContent);
173 const relativeFilePath = path.relative(configDir, post.filePath);
174 const postState = state.posts[relativeFilePath];
175
176 if (force) {
177 postsToPublish.push({
178 post,
179 action: post.frontmatter.atUri ? "update" : "create",
180 reason: "forced",
181 });
182 } else if (!postState) {
183 postsToPublish.push({
184 post,
185 action: post.frontmatter.atUri ? "update" : "create",
186 reason: post.frontmatter.atUri ? "missing state" : "new post",
187 });
188 } else if (postState.contentHash !== contentHash) {
189 // Changed post
190 postsToPublish.push({
191 post,
192 action: post.frontmatter.atUri ? "update" : "create",
193 reason: "content changed",
194 });
195 }
196 }
197
198 if (draftPosts.length > 0) {
199 log.info(
200 `Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`,
201 );
202 }
203
204 if (postsToPublish.length === 0) {
205 log.success("All posts are up to date. Nothing to publish.");
206 return;
207 }
208
209 log.info(`\n${postsToPublish.length} posts to publish:\n`);
210
211 // Bluesky posting configuration
212 const blueskyEnabled = config.bluesky?.enabled ?? false;
213 const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
214 const cutoffDate = new Date();
215 cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
216
217 for (const { post, action, reason } of postsToPublish) {
218 const icon = action === "create" ? "+" : "~";
219 const relativeFilePath = path.relative(configDir, post.filePath);
220 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
221
222 let bskyNote = "";
223 if (blueskyEnabled) {
224 if (existingBskyPostRef) {
225 bskyNote = " [bsky: exists]";
226 } else {
227 const publishDate = new Date(post.frontmatter.publishDate);
228 if (publishDate < cutoffDate) {
229 bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
230 } else {
231 bskyNote = " [bsky: will post]";
232 }
233 }
234 }
235
236 log.message(` ${icon} ${post.filePath} (${reason})${bskyNote}`);
237 }
238
239 if (dryRun) {
240 if (blueskyEnabled) {
241 log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
242 }
243 log.info("\nDry run complete. No changes made.");
244 return;
245 }
246
247 // Create agent
248 const connectingTo =
249 credentials.type === "oauth" ? credentials.handle : credentials.pdsUrl;
250 s.start(`Connecting as ${connectingTo}...`);
251 let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
252 try {
253 agent = await createAgent(credentials);
254 s.stop(`Logged in as ${agent.did}`);
255 } catch (error) {
256 s.stop("Failed to login");
257 log.error(`Failed to login: ${error}`);
258 process.exit(1);
259 }
260
261 // Publish posts
262 let publishedCount = 0;
263 let updatedCount = 0;
264 let errorCount = 0;
265 let bskyPostCount = 0;
266
267 const context: NoteOptions = {
268 contentDir,
269 imagesDir,
270 allPosts: posts,
271 };
272
273 // Pass 1: Create/update document records and collect note queue
274 const noteQueue: Array<{
275 post: BlogPost;
276 action: "create" | "update";
277 atUri: string;
278 }> = [];
279
280 for (const { post, action } of postsToPublish) {
281 s.start(`Publishing: ${post.frontmatter.title}`);
282
283 // Init publish date
284 if (!post.frontmatter.publishDate) {
285 const [publishDate] = new Date().toISOString().split("T")
286 post.frontmatter.publishDate = publishDate!
287 }
288
289 try {
290 // Handle cover image upload
291 let coverImage: BlobObject | undefined;
292 if (post.frontmatter.ogImage) {
293 const imagePath = await resolveImagePath(
294 post.frontmatter.ogImage,
295 imagesDir,
296 contentDir,
297 );
298
299 if (imagePath) {
300 log.info(` Uploading cover image: ${path.basename(imagePath)}`);
301 coverImage = await uploadImage(agent, imagePath);
302 if (coverImage) {
303 log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
304 }
305 } else {
306 log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
307 }
308 }
309
310 // Track atUri, content for state saving, and bskyPostRef
311 let atUri: string;
312 let contentForHash: string;
313 let bskyPostRef: StrongRef | undefined;
314 const relativeFilePath = path.relative(configDir, post.filePath);
315
316 // Check if bskyPostRef already exists in state
317 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
318
319 if (action === "create") {
320 atUri = await createDocument(agent, post, config, coverImage);
321 post.frontmatter.atUri = atUri;
322 s.stop(`Created: ${atUri}`);
323
324 // Update frontmatter with atUri
325 const updatedContent = updateFrontmatterWithAtUri(
326 post.rawContent,
327 atUri,
328 );
329 await fs.writeFile(post.filePath, updatedContent);
330 log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
331
332 // Use updated content (with atUri) for hash so next run sees matching hash
333 contentForHash = updatedContent;
334 publishedCount++;
335 } else {
336 atUri = post.frontmatter.atUri!;
337 await updateDocument(agent, post, atUri, config, coverImage);
338 s.stop(`Updated: ${atUri}`);
339
340 // For updates, rawContent already has atUri
341 contentForHash = post.rawContent;
342 updatedCount++;
343 }
344
345 // Create Bluesky post if enabled and conditions are met
346 if (blueskyEnabled) {
347 if (existingBskyPostRef) {
348 log.info(` Bluesky post already exists, skipping`);
349 bskyPostRef = existingBskyPostRef;
350 } else {
351 const publishDate = new Date(post.frontmatter.publishDate);
352
353 if (publishDate < cutoffDate) {
354 log.info(
355 ` Post is older than ${maxAgeDays} days, skipping Bluesky post`,
356 );
357 } else {
358 // Create Bluesky post
359 try {
360 const pathPrefix = config.pathPrefix || "/posts";
361 const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
362
363 bskyPostRef = await createBlueskyPost(agent, {
364 title: post.frontmatter.title,
365 description: post.frontmatter.description,
366 canonicalUrl,
367 coverImage,
368 publishedAt: post.frontmatter.publishDate,
369 });
370
371 // Update document record with bskyPostRef
372 await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
373 log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
374 bskyPostCount++;
375 } catch (bskyError) {
376 const errorMsg =
377 bskyError instanceof Error
378 ? bskyError.message
379 : String(bskyError);
380 log.warn(` Failed to create Bluesky post: ${errorMsg}`);
381 }
382 }
383 }
384 }
385
386 // Update state (use relative path from config directory)
387 const contentHash = await getContentHash(contentForHash);
388 state.posts[relativeFilePath] = {
389 contentHash,
390 atUri,
391 lastPublished: new Date().toISOString(),
392 slug: post.slug,
393 bskyPostRef,
394 };
395
396 noteQueue.push({ post, action, atUri });
397 } catch (error) {
398 const errorMessage =
399 error instanceof Error ? error.message : String(error);
400 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
401 log.error(` ${errorMessage}`);
402 errorCount++;
403 }
404 }
405
406 // Pass 2: Create/update litenote notes (atUris are now available for link resolution)
407 for (const { post, action, atUri } of noteQueue) {
408 try {
409 if (action === "create") {
410 await createNote(agent, post, atUri, context);
411 } else {
412 await updateNote(agent, post, atUri, context);
413 }
414 } catch (error) {
415 log.warn(
416 `Failed to create note for "${post.frontmatter.title}": ${error instanceof Error ? error.message : String(error)}`,
417 );
418 }
419 }
420
421 // Re-process already-published posts with stale links to newly created posts
422 const newlyCreatedSlugs = noteQueue
423 .filter((r) => r.action === "create")
424 .map((r) => r.post.slug);
425
426 if (newlyCreatedSlugs.length > 0) {
427 const batchFilePaths = new Set(noteQueue.map((r) => r.post.filePath));
428 const stalePosts = findPostsWithStaleLinks(
429 posts,
430 newlyCreatedSlugs,
431 batchFilePaths,
432 );
433
434 for (const stalePost of stalePosts) {
435 try {
436 s.start(`Updating links in: ${stalePost.frontmatter.title}`);
437 await updateNote(
438 agent,
439 stalePost,
440 stalePost.frontmatter.atUri!,
441 context,
442 );
443 s.stop(`Updated links: ${stalePost.frontmatter.title}`);
444 } catch (error) {
445 s.stop(`Failed to update links: ${stalePost.frontmatter.title}`);
446 log.warn(
447 ` ${error instanceof Error ? error.message : String(error)}`,
448 );
449 }
450 }
451 }
452
453 // Save state
454 await saveState(configDir, state);
455
456 // Summary
457 log.message("\n---");
458 log.info(`Published: ${publishedCount}`);
459 log.info(`Updated: ${updatedCount}`);
460 if (bskyPostCount > 0) {
461 log.info(`Bluesky posts: ${bskyPostCount}`);
462 }
463 if (errorCount > 0) {
464 log.warn(`Errors: ${errorCount}`);
465 }
466 },
467});