A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
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";
28
29export const publishCommand = command({
30 name: "publish",
31 description: "Publish content to ATProto",
32 args: {
33 force: flag({
34 long: "force",
35 short: "f",
36 description: "Force publish all posts, ignoring change detection",
37 }),
38 dryRun: flag({
39 long: "dry-run",
40 short: "n",
41 description: "Preview what would be published without making changes",
42 }),
43 },
44 handler: async ({ force, dryRun }) => {
45 // Load config
46 const configPath = await findConfig();
47 if (!configPath) {
48 log.error("No publisher.config.ts found. Run 'publisher init' first.");
49 process.exit(1);
50 }
51
52 const config = await loadConfig(configPath);
53 const configDir = path.dirname(configPath);
54
55 log.info(`Site: ${config.siteUrl}`);
56 log.info(`Content directory: ${config.contentDir}`);
57
58 // Load credentials
59 let credentials = await loadCredentials(config.identity);
60
61 // If no credentials resolved, check if we need to prompt for identity selection
62 if (!credentials) {
63 const identities = await listAllCredentials();
64 if (identities.length === 0) {
65 log.error(
66 "No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
67 );
68 log.info(
69 "Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
70 );
71 process.exit(1);
72 }
73
74 // Build labels with handles for OAuth sessions
75 const options = await Promise.all(
76 identities.map(async (cred) => {
77 if (cred.type === "oauth") {
78 const handle = await getOAuthHandle(cred.id);
79 return {
80 value: cred.id,
81 label: `${handle || cred.id} (OAuth)`,
82 };
83 }
84 return {
85 value: cred.id,
86 label: `${cred.id} (App Password)`,
87 };
88 }),
89 );
90
91 // Multiple identities exist but none selected - prompt user
92 log.info("Multiple identities found. Select one to use:");
93 const selected = exitOnCancel(
94 await select({
95 message: "Identity:",
96 options,
97 }),
98 );
99
100 // Load the selected credentials
101 const selectedCred = identities.find((c) => c.id === selected);
102 if (selectedCred?.type === "oauth") {
103 const session = await getOAuthSession(selected);
104 if (session) {
105 const handle = await getOAuthHandle(selected);
106 credentials = {
107 type: "oauth",
108 did: selected,
109 handle: handle || selected,
110 };
111 }
112 } else {
113 credentials = await getCredentials(selected);
114 }
115
116 if (!credentials) {
117 log.error("Failed to load selected credentials.");
118 process.exit(1);
119 }
120
121 const displayId =
122 credentials.type === "oauth"
123 ? credentials.handle || credentials.did
124 : credentials.identifier;
125 log.info(
126 `Tip: Add "identity": "${displayId}" to sequoia.json to use this by default.`,
127 );
128 }
129
130 // Resolve content directory
131 const contentDir = path.isAbsolute(config.contentDir)
132 ? config.contentDir
133 : path.join(configDir, config.contentDir);
134
135 const imagesDir = config.imagesDir
136 ? path.isAbsolute(config.imagesDir)
137 ? config.imagesDir
138 : path.join(configDir, config.imagesDir)
139 : undefined;
140
141 // Load state
142 const state = await loadState(configDir);
143
144 // Scan for posts
145 const s = spinner();
146 s.start("Scanning for posts...");
147 const posts = await scanContentDirectory(contentDir, {
148 frontmatterMapping: config.frontmatter,
149 ignorePatterns: config.ignore,
150 slugField: config.frontmatter?.slugField,
151 removeIndexFromSlug: config.removeIndexFromSlug,
152 stripDatePrefix: config.stripDatePrefix,
153 });
154 s.stop(`Found ${posts.length} posts`);
155
156 // Determine which posts need publishing
157 const postsToPublish: Array<{
158 post: BlogPost;
159 action: "create" | "update";
160 reason: string;
161 }> = [];
162 const draftPosts: BlogPost[] = [];
163
164 for (const post of posts) {
165 // Skip draft posts
166 if (post.frontmatter.draft) {
167 draftPosts.push(post);
168 continue;
169 }
170
171 const contentHash = await getContentHash(post.rawContent);
172 const relativeFilePath = path.relative(configDir, post.filePath);
173 const postState = state.posts[relativeFilePath];
174
175 if (force) {
176 postsToPublish.push({
177 post,
178 action: post.frontmatter.atUri ? "update" : "create",
179 reason: "forced",
180 });
181 } else if (!postState) {
182 // New post
183 postsToPublish.push({
184 post,
185 action: "create",
186 reason: "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.frontmatter.title} (${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 for (const { post, action } of postsToPublish) {
268 s.start(`Publishing: ${post.frontmatter.title}`);
269
270 try {
271 // Handle cover image upload
272 let coverImage: BlobObject | undefined;
273 if (post.frontmatter.ogImage) {
274 const imagePath = await resolveImagePath(
275 post.frontmatter.ogImage,
276 imagesDir,
277 contentDir,
278 );
279
280 if (imagePath) {
281 log.info(` Uploading cover image: ${path.basename(imagePath)}`);
282 coverImage = await uploadImage(agent, imagePath);
283 if (coverImage) {
284 log.info(` Uploaded image blob: ${coverImage.ref.$link}`);
285 }
286 } else {
287 log.warn(` Cover image not found: ${post.frontmatter.ogImage}`);
288 }
289 }
290
291 // Track atUri, content for state saving, and bskyPostRef
292 let atUri: string;
293 let contentForHash: string;
294 let bskyPostRef: StrongRef | undefined;
295 const relativeFilePath = path.relative(configDir, post.filePath);
296
297 // Check if bskyPostRef already exists in state
298 const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
299
300 if (action === "create") {
301 atUri = await createDocument(agent, post, config, coverImage);
302 s.stop(`Created: ${atUri}`);
303
304 // Update frontmatter with atUri
305 const updatedContent = updateFrontmatterWithAtUri(
306 post.rawContent,
307 atUri,
308 );
309 await fs.writeFile(post.filePath, updatedContent);
310 log.info(` Updated frontmatter in ${path.basename(post.filePath)}`);
311
312 // Use updated content (with atUri) for hash so next run sees matching hash
313 contentForHash = updatedContent;
314 publishedCount++;
315 } else {
316 atUri = post.frontmatter.atUri!;
317 await updateDocument(agent, post, atUri, config, coverImage);
318 s.stop(`Updated: ${atUri}`);
319
320 // For updates, rawContent already has atUri
321 contentForHash = post.rawContent;
322 updatedCount++;
323 }
324
325 // Create Bluesky post if enabled and conditions are met
326 if (blueskyEnabled) {
327 if (existingBskyPostRef) {
328 log.info(` Bluesky post already exists, skipping`);
329 bskyPostRef = existingBskyPostRef;
330 } else {
331 const publishDate = new Date(post.frontmatter.publishDate);
332
333 if (publishDate < cutoffDate) {
334 log.info(
335 ` Post is older than ${maxAgeDays} days, skipping Bluesky post`,
336 );
337 } else {
338 // Create Bluesky post
339 try {
340 const pathPrefix = config.pathPrefix || "/posts";
341 const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
342
343 bskyPostRef = await createBlueskyPost(agent, {
344 title: post.frontmatter.title,
345 description: post.frontmatter.description,
346 canonicalUrl,
347 coverImage,
348 publishedAt: post.frontmatter.publishDate,
349 });
350
351 // Update document record with bskyPostRef
352 await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
353 log.info(` Created Bluesky post: ${bskyPostRef.uri}`);
354 bskyPostCount++;
355 } catch (bskyError) {
356 const errorMsg =
357 bskyError instanceof Error
358 ? bskyError.message
359 : String(bskyError);
360 log.warn(` Failed to create Bluesky post: ${errorMsg}`);
361 }
362 }
363 }
364 }
365
366 // Update state (use relative path from config directory)
367 const contentHash = await getContentHash(contentForHash);
368 state.posts[relativeFilePath] = {
369 contentHash,
370 atUri,
371 lastPublished: new Date().toISOString(),
372 slug: post.slug,
373 bskyPostRef,
374 };
375 } catch (error) {
376 const errorMessage =
377 error instanceof Error ? error.message : String(error);
378 s.stop(`Error publishing "${path.basename(post.filePath)}"`);
379 log.error(` ${errorMessage}`);
380 errorCount++;
381 }
382 }
383
384 // Save state
385 await saveState(configDir, state);
386
387 // Summary
388 log.message("\n---");
389 log.info(`Published: ${publishedCount}`);
390 log.info(`Updated: ${updatedCount}`);
391 if (bskyPostCount > 0) {
392 log.info(`Bluesky posts: ${bskyPostCount}`);
393 }
394 if (errorCount > 0) {
395 log.warn(`Errors: ${errorCount}`);
396 }
397 },
398});