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