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