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