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
		});
114
		s.stop(`Found ${posts.length} posts`);
115
116
		// Determine which posts need publishing
117
		const postsToPublish: Array<{
118
			post: BlogPost;
119
			action: "create" | "update";
120
			reason: string;
121
		}> = [];
122
		const draftPosts: BlogPost[] = [];
123
124
		for (const post of posts) {
125
			// Skip draft posts
126
			if (post.frontmatter.draft) {
127
				draftPosts.push(post);
128
				continue;
129
			}
130
131
			const contentHash = await getContentHash(post.rawContent);
132
			const relativeFilePath = path.relative(configDir, post.filePath);
133
			const postState = state.posts[relativeFilePath];
134
135
			if (force) {
136
				postsToPublish.push({
137
					post,
138
					action: post.frontmatter.atUri ? "update" : "create",
139
					reason: "forced",
140
				});
141
			} else if (!postState) {
142
				// New post
143
				postsToPublish.push({
144
					post,
145
					action: "create",
146
					reason: "new post",
147
				});
148
			} else if (postState.contentHash !== contentHash) {
149
				// Changed post
150
				postsToPublish.push({
151
					post,
152
					action: post.frontmatter.atUri ? "update" : "create",
153
					reason: "content changed",
154
				});
155
			}
156
		}
157
158
		if (draftPosts.length > 0) {
159
			log.info(
160
				`Skipping ${draftPosts.length} draft post${draftPosts.length === 1 ? "" : "s"}`,
161
			);
162
		}
163
164
		if (postsToPublish.length === 0) {
165
			log.success("All posts are up to date. Nothing to publish.");
166
			return;
167
		}
168
169
		log.info(`\n${postsToPublish.length} posts to publish:\n`);
170
171
		// Bluesky posting configuration
172
		const blueskyEnabled = config.bluesky?.enabled ?? false;
173
		const maxAgeDays = config.bluesky?.maxAgeDays ?? 7;
174
		const cutoffDate = new Date();
175
		cutoffDate.setDate(cutoffDate.getDate() - maxAgeDays);
176
177
		for (const { post, action, reason } of postsToPublish) {
178
			const icon = action === "create" ? "+" : "~";
179
			const relativeFilePath = path.relative(configDir, post.filePath);
180
			const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
181
182
			let bskyNote = "";
183
			if (blueskyEnabled) {
184
				if (existingBskyPostRef) {
185
					bskyNote = " [bsky: exists]";
186
				} else {
187
					const publishDate = new Date(post.frontmatter.publishDate);
188
					if (publishDate < cutoffDate) {
189
						bskyNote = ` [bsky: skipped, older than ${maxAgeDays} days]`;
190
					} else {
191
						bskyNote = " [bsky: will post]";
192
					}
193
				}
194
			}
195
196
			log.message(`  ${icon} ${post.frontmatter.title} (${reason})${bskyNote}`);
197
		}
198
199
		if (dryRun) {
200
			if (blueskyEnabled) {
201
				log.info(`\nBluesky posting: enabled (max age: ${maxAgeDays} days)`);
202
			}
203
			log.info("\nDry run complete. No changes made.");
204
			return;
205
		}
206
207
		// Create agent
208
		s.start(`Connecting to ${credentials.pdsUrl}...`);
209
		let agent: Awaited<ReturnType<typeof createAgent>> | undefined;
210
		try {
211
			agent = await createAgent(credentials);
212
			s.stop(`Logged in as ${agent.session?.handle}`);
213
		} catch (error) {
214
			s.stop("Failed to login");
215
			log.error(`Failed to login: ${error}`);
216
			process.exit(1);
217
		}
218
219
		// Publish posts
220
		let publishedCount = 0;
221
		let updatedCount = 0;
222
		let errorCount = 0;
223
		let bskyPostCount = 0;
224
225
		for (const { post, action } of postsToPublish) {
226
			s.start(`Publishing: ${post.frontmatter.title}`);
227
228
			try {
229
				// Handle cover image upload
230
				let coverImage: BlobObject | undefined;
231
				if (post.frontmatter.ogImage) {
232
					const imagePath = await resolveImagePath(
233
						post.frontmatter.ogImage,
234
						imagesDir,
235
						contentDir,
236
					);
237
238
					if (imagePath) {
239
						log.info(`  Uploading cover image: ${path.basename(imagePath)}`);
240
						coverImage = await uploadImage(agent, imagePath);
241
						if (coverImage) {
242
							log.info(`  Uploaded image blob: ${coverImage.ref.$link}`);
243
						}
244
					} else {
245
						log.warn(`  Cover image not found: ${post.frontmatter.ogImage}`);
246
					}
247
				}
248
249
				// Track atUri, content for state saving, and bskyPostRef
250
				let atUri: string;
251
				let contentForHash: string;
252
				let bskyPostRef: StrongRef | undefined;
253
				const relativeFilePath = path.relative(configDir, post.filePath);
254
255
				// Check if bskyPostRef already exists in state
256
				const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef;
257
258
				if (action === "create") {
259
					atUri = await createDocument(agent, post, config, coverImage);
260
					s.stop(`Created: ${atUri}`);
261
262
					// Update frontmatter with atUri
263
					const updatedContent = updateFrontmatterWithAtUri(
264
						post.rawContent,
265
						atUri,
266
					);
267
					await fs.writeFile(post.filePath, updatedContent);
268
					log.info(`  Updated frontmatter in ${path.basename(post.filePath)}`);
269
270
					// Use updated content (with atUri) for hash so next run sees matching hash
271
					contentForHash = updatedContent;
272
					publishedCount++;
273
				} else {
274
					atUri = post.frontmatter.atUri!;
275
					await updateDocument(agent, post, atUri, config, coverImage);
276
					s.stop(`Updated: ${atUri}`);
277
278
					// For updates, rawContent already has atUri
279
					contentForHash = post.rawContent;
280
					updatedCount++;
281
				}
282
283
				// Create Bluesky post if enabled and conditions are met
284
				if (blueskyEnabled) {
285
					if (existingBskyPostRef) {
286
						log.info(`  Bluesky post already exists, skipping`);
287
						bskyPostRef = existingBskyPostRef;
288
					} else {
289
						const publishDate = new Date(post.frontmatter.publishDate);
290
291
						if (publishDate < cutoffDate) {
292
							log.info(
293
								`  Post is older than ${maxAgeDays} days, skipping Bluesky post`,
294
							);
295
						} else {
296
							// Create Bluesky post
297
							try {
298
								const pathPrefix = config.pathPrefix || "/posts";
299
								const canonicalUrl = `${config.siteUrl}${pathPrefix}/${post.slug}`;
300
301
								bskyPostRef = await createBlueskyPost(agent, {
302
									title: post.frontmatter.title,
303
									description: post.frontmatter.description,
304
									canonicalUrl,
305
									coverImage,
306
									publishedAt: post.frontmatter.publishDate,
307
								});
308
309
								// Update document record with bskyPostRef
310
								await addBskyPostRefToDocument(agent, atUri, bskyPostRef);
311
								log.info(`  Created Bluesky post: ${bskyPostRef.uri}`);
312
								bskyPostCount++;
313
							} catch (bskyError) {
314
								const errorMsg =
315
									bskyError instanceof Error
316
										? bskyError.message
317
										: String(bskyError);
318
								log.warn(`  Failed to create Bluesky post: ${errorMsg}`);
319
							}
320
						}
321
					}
322
				}
323
324
				// Update state (use relative path from config directory)
325
				const contentHash = await getContentHash(contentForHash);
326
				state.posts[relativeFilePath] = {
327
					contentHash,
328
					atUri,
329
					lastPublished: new Date().toISOString(),
330
					slug: post.slug,
331
					bskyPostRef,
332
				};
333
			} catch (error) {
334
				const errorMessage =
335
					error instanceof Error ? error.message : String(error);
336
				s.stop(`Error publishing "${path.basename(post.filePath)}"`);
337
				log.error(`  ${errorMessage}`);
338
				errorCount++;
339
			}
340
		}
341
342
		// Save state
343
		await saveState(configDir, state);
344
345
		// Summary
346
		log.message("\n---");
347
		log.info(`Published: ${publishedCount}`);
348
		log.info(`Updated: ${updatedCount}`);
349
		if (bskyPostCount > 0) {
350
			log.info(`Bluesky posts: ${bskyPostCount}`);
351
		}
352
		if (errorCount > 0) {
353
			log.warn(`Errors: ${errorCount}`);
354
		}
355
	},
356
});