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