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