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