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