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