packages/cli/src/commands/publish.ts 10.0 K raw
1
import * as fs from "node:fs/promises";
2
import { command, flag } from "cmd-ts";
3
import { select, spinner, log } from "@clack/prompts";
4
import * as path from "node:path";
5
import { loadConfig, loadState, saveState, findConfig } from "../lib/config";
6
import {
7
	loadCredentials,
8
	listCredentials,
9
	getCredentials,
10
} from "../lib/credentials";
11
import {
12
	createAgent,
13
	createDocument,
14
	updateDocument,
15
	uploadImage,
16
	resolveImagePath,
17
	createBlueskyPost,
18
	addBskyPostRefToDocument,
19
} from "../lib/atproto";
20
import {
21
	scanContentDirectory,
22
	getContentHash,
23
	updateFrontmatterWithAtUri,
24
} from "../lib/markdown";
25
import type { BlogPost, BlobObject, StrongRef } from "../lib/types";
26
import { exitOnCancel } from "../lib/prompts";
27
28
export const publishCommand = command({
29
	name: "publish",
30
	description: "Publish content to ATProto",
31
	args: {
32
		force: flag({
33
			long: "force",
34
			short: "f",
35
			description: "Force publish all posts, ignoring change detection",
36
		}),
37
		dryRun: flag({
38
			long: "dry-run",
39
			short: "n",
40
			description: "Preview what would be published without making changes",
41
		}),
42
	},
43
	handler: async ({ force, dryRun }) => {
44
		// Load config
45
		const configPath = await findConfig();
46
		if (!configPath) {
47
			log.error("No publisher.config.ts found. Run 'publisher init' first.");
48
			process.exit(1);
49
		}
50
51
		const config = await loadConfig(configPath);
52
		const configDir = path.dirname(configPath);
53
54
		log.info(`Site: ${config.siteUrl}`);
55
		log.info(`Content directory: ${config.contentDir}`);
56
57
		// Load credentials
58
		let credentials = await loadCredentials(config.identity);
59
60
		// If no credentials resolved, check if we need to prompt for identity selection
61
		if (!credentials) {
62
			const identities = await listCredentials();
63
			if (identities.length === 0) {
64
				log.error("No credentials found. Run 'sequoia auth' first.");
65
				log.info(
66
					"Or set ATP_IDENTIFIER and ATP_APP_PASSWORD environment variables.",
67
				);
68
				process.exit(1);
69
			}
70
71
			// Multiple identities exist but none selected - prompt user
72
			log.info("Multiple identities found. Select one to use:");
73
			const selected = exitOnCancel(
74
				await select({
75
					message: "Identity:",
76
					options: identities.map((id) => ({ value: id, label: id })),
77
				}),
78
			);
79
80
			credentials = await getCredentials(selected);
81
			if (!credentials) {
82
				log.error("Failed to load selected credentials.");
83
				process.exit(1);
84
			}
85
86
			log.info(
87
				`Tip: Add "identity": "${selected}" to sequoia.json to use this by default.`,
88
			);
89
		}
90
91
		// Resolve content directory
92
		const contentDir = path.isAbsolute(config.contentDir)
93
			? config.contentDir
94
			: path.join(configDir, config.contentDir);
95
96
		const imagesDir = config.imagesDir
97
			? path.isAbsolute(config.imagesDir)
98
				? config.imagesDir
99
				: path.join(configDir, config.imagesDir)
100
			: undefined;
101
102
		// Load state
103
		const state = await loadState(configDir);
104
105
		// Scan for posts
106
		const s = spinner();
107
		s.start("Scanning for posts...");
108
		const posts = await scanContentDirectory(contentDir, {
109
			frontmatterMapping: config.frontmatter,
110
			ignorePatterns: config.ignore,
111
			slugField: config.frontmatter?.slugField,
112
			removeIndexFromSlug: config.removeIndexFromSlug,
113
			stripDatePrefix: config.stripDatePrefix,
114
		});
115
		s.stop(`Found ${posts.length} posts`);
116
117
		// Determine which posts need publishing
118
		const postsToPublish: Array<{
119
			post: BlogPost;
120
			action: "create" | "update";
121
			reason: string;
122
		}> = [];
123
		const draftPosts: BlogPost[] = [];
124
125
		for (const post of posts) {
126
			// Skip draft posts
127
			if (post.frontmatter.draft) {
128
				draftPosts.push(post);
129
				continue;
130
			}
131
132
			const contentHash = await getContentHash(post.rawContent);
133
			const relativeFilePath = path.relative(configDir, post.filePath);
134
			const postState = state.posts[relativeFilePath];
135
136
			if (force) {
137
				postsToPublish.push({
138
					post,
139
					action: post.frontmatter.atUri ? "update" : "create",
140
					reason: "forced",
141
				});
142
			} else if (!postState) {
143
				// New post
144
				postsToPublish.push({
145
					post,
146
					action: "create",
147
					reason: "new post",
148
				});
149
			} else if (postState.contentHash !== contentHash) {
150
				// Changed post
151
				postsToPublish.push({
152
					post,
153
					action: post.frontmatter.atUri ? "update" : "create",
154
					reason: "content changed",
155
				});
156
			}
157
		}
158
159
		if (draftPosts.length > 0) {
160
			log.info(
161
				`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`,
162
			);
163
		}
164
165
		if (postsToPublish.length === 0) {
166
			log.success("All posts are up to date. Nothing to publish.");
167
			return;
168
		}
169
170
		log.info(`\n${postsToPublish.length} posts to publish:\n`);
171
172
		// Bluesky posting configuration
173
		const blueskyEnabled = config.bluesky?.enabled ?? false;
174
		const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
175
		const cutoffDate = new Date();
176
		cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
177
178
		for (const { post, action, reason } of postsToPublish) {
179
			const icon = action === "create" ? "+" : "~";
180
			const relativeFilePath = path.relative(configDir, post.filePath);
181
			const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
182
183
			let bskyNote = "";
184
			if (blueskyEnabled) {
185
				if (existingBskyPostRef) {
186
					bskyNote = " [bsky: exists]";
187
				} else {
188
					const publishDate = new Date(post.frontmatter.publishDate);
189
					if (publishDate < cutoffDate) {
190
						bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
191
					} else {
192
						bskyNote = " [bsky: will post]";
193
					}
194
				}
195
			}
196
197
			log.message(`  ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
198
		}
199
200
		if (dryRun) {
201
			if (blueskyEnabled) {
202
				log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
203
			}
204
			log.info("\nDry run complete. No changes made.");
205
			return;
206
		}
207
208
		// Create agent
209
		s.start(`Connecting to ${credentials.pdsUrl}...`);
210
		let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
211
		try {
212
			agent = await createAgent(credentials);
213
			s.stop(`Logged in as ${agent.did}`);
214
		} catch (error) {
215
			s.stop("Failed to login");
216
			log.error(`Failed to login: ${error}`);
217
			process.exit(1);
218
		}
219
220
		// Publish posts
221
		let publishedCount = 0;
222
		let updatedCount = 0;
223
		let errorCount = 0;
224
		let bskyPostCount = 0;
225
226
		for (const { post, action } of postsToPublish) {
227
			s.start(`Publishing: ${post.frontmatter.title}`);
228
229
			try {
230
				// Handle cover image upload
231
				let coverImage: BlobObject | undefined;
232
				if (post.frontmatter.ogImage) {
233
					const imagePath = await resolveImagePath(
234
						post.frontmatter.ogImage,
235
						imagesDir,
236
						contentDir,
237
					);
238
239
					if (imagePath) {
240
						log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
241
						coverImage = await uploadImage(agent, imagePath);
242
						if (coverImage) {
243
							log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
244
						}
245
					} else {
246
						log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
247
					}
248
				}
249
250
				// Track atUri, content for state saving, and bskyPostRef
251
				let atUri: string;
252
				let contentForHash: string;
253
				let bskyPostRef: StrongRef | undefined;
254
				const relativeFilePath = path.relative(configDir, post.filePath);
255
256
				// Check if bskyPostRef already exists in state
257
				const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
258
259
				if (action === "create") {
260
					atUri = await createDocument(agent, post, config, coverImage);
261
					s.stop(`Created: ${atUri}`);
262
263
					// Update frontmatter with atUri
264
					const updatedContent = updateFrontmatterWithAtUri(
265
						post.rawContent,
266
						atUri,
267
					);
268
					await fs.writeFile(post.filePath, updatedContent);
269
					log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
270
271
					// Use updated content (with atUri) for hash so next run sees matching hash
272
					contentForHash = updatedContent;
273
					publishedCount++;
274
				} else {
275
					atUri = post.frontmatter.atUri!;
276
					await updateDocument(agent, post, atUri, config, coverImage);
277
					s.stop(`Updated: ${atUri}`);
278
279
					// For updates, rawContent already has atUri
280
					contentForHash = post.rawContent;
281
					updatedCount++;
282
				}
283
284
				// Create Bluesky post if enabled and conditions are met
285
				if (blueskyEnabled) {
286
					if (existingBskyPostRef) {
287
						log.info(`  Bluesky post already exists, skipping`);
288
						bskyPostRef = existingBskyPostRef;
289
					} else {
290
						const publishDate = new Date(post.frontmatter.publishDate);
291
292
						if (publishDate < cutoffDate) {
293
							log.info(
294
								`  Post is older than ${maxAgeDays} days, skipping Bluesky post`,
295
							);
296
						} else {
297
							// Create Bluesky post
298
							try {
299
								const pathPrefix = config.pathPrefix || "/posts";
300
								const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
301
302
								bskyPostRef = await createBlueskyPost(agent, {
303
									title: post.frontmatter.title,
304
									description: post.frontmatter.description,
305
									canonicalUrl,
306
									coverImage,
307
									publishedAt: post.frontmatter.publishDate,
308
								});
309
310
								// Update document record with bskyPostRef
311
								await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
312
								log.info(`  Created Bluesky post: ${bskyPostRef.uri}`);
313
								bskyPostCount++;
314
							} catch (bskyError) {
315
								const errorMsg =
316
									bskyError instanceof Error
317
										? bskyError.message
318
										: String(bskyError);
319
								log.warn(`  Failed to create Bluesky post: ${errorMsg}`);
320
							}
321
						}
322
					}
323
				}
324
325
				// Update state (use relative path from config directory)
326
				const contentHash = await getContentHash(contentForHash);
327
				state.posts[relativeFilePath] = {
328
					contentHash,
329
					atUri,
330
					lastPublished: new Date().toISOString(),
331
					slug: post.slug,
332
					bskyPostRef,
333
				};
334
			} catch (error) {
335
				const errorMessage =
336
					error instanceof Error ? error.message : String(error);
337
				s.stop(`Error publishing "${path.basename(post.filePath)}"`);
338
				log.error(`  ${errorMessage}`);
339
				errorCount++;
340
			}
341
		}
342
343
		// Save state
344
		await saveState(configDir, state);
345
346
		// Summary
347
		log.message("\n---");
348
		log.info(`Published: ${publishedCount}`);
349
		log.info(`Updated: ${updatedCount}`);
350
		if (bskyPostCount > 0) {
351
			log.info(`Bluesky posts: ${bskyPostCount}`);
352
		}
353
		if (errorCount > 0) {
354
			log.warn(`Errors: ${errorCount}`);
355
		}
356
	},
357
});