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