packages/cli/src/commands/update.ts 14.6 K raw
1
import * as fs from "node:fs/promises";
2
import { command } from "cmd-ts";
3
import {
4
	intro,
5
	outro,
6
	note,
7
	text,
8
	confirm,
9
	select,
10
	spinner,
11
	log,
12
} from "@clack/prompts";
13
import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config";
14
import {
15
	loadCredentials,
16
	listAllCredentials,
17
	getCredentials,
18
} from "../lib/credentials";
19
import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";
20
import { createAgent, getPublication, updatePublication } from "../lib/atproto";
21
import { exitOnCancel } from "../lib/prompts";
22
import type {
23
	PublisherConfig,
24
	FrontmatterMapping,
25
	BlueskyConfig,
26
} from "../lib/types";
27
28
export const updateCommand = command({
29
	name: "update",
30
	description: "Update local config or ATProto publication record",
31
	args: {},
32
	handler: async () => {
33
		intro("Sequoia Update");
34
35
		// Check if config exists
36
		const configPath = await findConfig();
37
		if (!configPath) {
38
			log.error("No configuration found. Run 'sequoia init' first.");
39
			process.exit(1);
40
		}
41
42
		const config = await loadConfig(configPath);
43
44
		// Ask what to update
45
		const updateChoice = exitOnCancel(
46
			await select({
47
				message: "What would you like to update?",
48
				options: [
49
					{ label: "Local configuration (sequoia.json)", value: "config" },
50
					{ label: "ATProto publication record", value: "publication" },
51
				],
52
			}),
53
		);
54
55
		if (updateChoice === "config") {
56
			await updateConfigFlow(config, configPath);
57
		} else {
58
			await updatePublicationFlow(config);
59
		}
60
61
		outro("Update complete!");
62
	},
63
});
64
65
async function updateConfigFlow(
66
	config: PublisherConfig,
67
	configPath: string,
68
): Promise<void> {
69
	// Show current config summary
70
	const configSummary = [
71
		`Site URL: ${config.siteUrl}`,
72
		`Content Dir: ${config.contentDir}`,
73
		`Path Prefix: ${config.pathPrefix || "/posts"}`,
74
		`Publication URI: ${config.publicationUri}`,
75
		config.imagesDir ? `Images Dir: ${config.imagesDir}` : null,
76
		config.outputDir ? `Output Dir: ${config.outputDir}` : null,
77
		config.bluesky?.enabled ? `Bluesky: enabled` : null,
78
	]
79
		.filter(Boolean)
80
		.join("\n");
81
82
	note(configSummary, "Current Configuration");
83
84
	let configUpdated = { ...config };
85
	let editing = true;
86
87
	while (editing) {
88
		const section = exitOnCancel(
89
			await select({
90
				message: "Select a section to edit:",
91
				options: [
92
					{ label: "Site settings (siteUrl, pathPrefix)", value: "site" },
93
					{
94
						label:
95
							"Directory paths (contentDir, imagesDir, publicDir, outputDir)",
96
						value: "directories",
97
					},
98
					{
99
						label:
100
							"Frontmatter mappings (title, description, publishDate, etc.)",
101
						value: "frontmatter",
102
					},
103
					{
104
						label:
105
							"Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)",
106
						value: "advanced",
107
					},
108
					{
109
						label: "Bluesky settings (enabled, maxAgeDays)",
110
						value: "bluesky",
111
					},
112
					{ label: "Done editing", value: "done" },
113
				],
114
			}),
115
		);
116
117
		if (section === "done") {
118
			editing = false;
119
			continue;
120
		}
121
122
		switch (section) {
123
			case "site":
124
				configUpdated = await editSiteSettings(configUpdated);
125
				break;
126
			case "directories":
127
				configUpdated = await editDirectories(configUpdated);
128
				break;
129
			case "frontmatter":
130
				configUpdated = await editFrontmatter(configUpdated);
131
				break;
132
			case "advanced":
133
				configUpdated = await editAdvanced(configUpdated);
134
				break;
135
			case "bluesky":
136
				configUpdated = await editBluesky(configUpdated);
137
				break;
138
		}
139
	}
140
141
	// Confirm before saving
142
	const shouldSave = exitOnCancel(
143
		await confirm({
144
			message: "Save changes to sequoia.json?",
145
			initialValue: true,
146
		}),
147
	);
148
149
	if (shouldSave) {
150
		const configContent = generateConfigTemplate({
151
			siteUrl: configUpdated.siteUrl,
152
			contentDir: configUpdated.contentDir,
153
			imagesDir: configUpdated.imagesDir,
154
			publicDir: configUpdated.publicDir,
155
			outputDir: configUpdated.outputDir,
156
			pathPrefix: configUpdated.pathPrefix,
157
			publicationUri: configUpdated.publicationUri,
158
			pdsUrl: configUpdated.pdsUrl,
159
			frontmatter: configUpdated.frontmatter,
160
			ignore: configUpdated.ignore,
161
			removeIndexFromSlug: configUpdated.removeIndexFromSlug,
162
			stripDatePrefix: configUpdated.stripDatePrefix,
163
			pathTemplate: configUpdated.pathTemplate,
164
			textContentField: configUpdated.textContentField,
165
			bluesky: configUpdated.bluesky,
166
		});
167
168
		await fs.writeFile(configPath, configContent);
169
		log.success("Configuration saved!");
170
	} else {
171
		log.info("Changes discarded.");
172
	}
173
}
174
175
async function editSiteSettings(
176
	config: PublisherConfig,
177
): Promise<PublisherConfig> {
178
	const siteUrl = exitOnCancel(
179
		await text({
180
			message: "Site URL:",
181
			initialValue: config.siteUrl,
182
			validate: (value) => {
183
				if (!value) return "Site URL is required";
184
				try {
185
					new URL(value);
186
				} catch {
187
					return "Please enter a valid URL";
188
				}
189
			},
190
		}),
191
	);
192
193
	const pathPrefix = exitOnCancel(
194
		await text({
195
			message: "URL path prefix for posts:",
196
			initialValue: config.pathPrefix || "/posts",
197
		}),
198
	);
199
200
	return {
201
		...config,
202
		siteUrl,
203
		pathPrefix: pathPrefix || undefined,
204
	};
205
}
206
207
async function editDirectories(
208
	config: PublisherConfig,
209
): Promise<PublisherConfig> {
210
	const contentDir = exitOnCancel(
211
		await text({
212
			message: "Content directory:",
213
			initialValue: config.contentDir,
214
			validate: (value) => {
215
				if (!value) return "Content directory is required";
216
			},
217
		}),
218
	);
219
220
	const imagesDir = exitOnCancel(
221
		await text({
222
			message: "Cover images directory (leave empty to skip):",
223
			initialValue: config.imagesDir || "",
224
		}),
225
	);
226
227
	const publicDir = exitOnCancel(
228
		await text({
229
			message: "Public/static directory:",
230
			initialValue: config.publicDir || "./public",
231
		}),
232
	);
233
234
	const outputDir = exitOnCancel(
235
		await text({
236
			message: "Build output directory:",
237
			initialValue: config.outputDir || "./dist",
238
		}),
239
	);
240
241
	return {
242
		...config,
243
		contentDir,
244
		imagesDir: imagesDir || undefined,
245
		publicDir: publicDir || undefined,
246
		outputDir: outputDir || undefined,
247
	};
248
}
249
250
async function editFrontmatter(
251
	config: PublisherConfig,
252
): Promise<PublisherConfig> {
253
	const currentFrontmatter = config.frontmatter || {};
254
255
	log.info("Press Enter to keep current value, or type a new field name.");
256
257
	const titleField = exitOnCancel(
258
		await text({
259
			message: "Field name for title:",
260
			initialValue: currentFrontmatter.title || "title",
261
		}),
262
	);
263
264
	const descField = exitOnCancel(
265
		await text({
266
			message: "Field name for description:",
267
			initialValue: currentFrontmatter.description || "description",
268
		}),
269
	);
270
271
	const dateField = exitOnCancel(
272
		await text({
273
			message: "Field name for publish date:",
274
			initialValue: currentFrontmatter.publishDate || "publishDate",
275
		}),
276
	);
277
278
	const coverField = exitOnCancel(
279
		await text({
280
			message: "Field name for cover image:",
281
			initialValue: currentFrontmatter.coverImage || "ogImage",
282
		}),
283
	);
284
285
	const tagsField = exitOnCancel(
286
		await text({
287
			message: "Field name for tags:",
288
			initialValue: currentFrontmatter.tags || "tags",
289
		}),
290
	);
291
292
	const draftField = exitOnCancel(
293
		await text({
294
			message: "Field name for draft status:",
295
			initialValue: currentFrontmatter.draft || "draft",
296
		}),
297
	);
298
299
	const slugField = exitOnCancel(
300
		await text({
301
			message: "Field name for slug (leave empty to use filepath):",
302
			initialValue: currentFrontmatter.slugField || "",
303
		}),
304
	);
305
306
	// Build frontmatter mapping, only including non-default values
307
	const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
308
		["title", titleField, "title"],
309
		["description", descField, "description"],
310
		["publishDate", dateField, "publishDate"],
311
		["coverImage", coverField, "ogImage"],
312
		["tags", tagsField, "tags"],
313
		["draft", draftField, "draft"],
314
	];
315
316
	const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
317
		(acc, [key, value, defaultValue]) => {
318
			if (value !== defaultValue) {
319
				acc[key] = value;
320
			}
321
			return acc;
322
		},
323
		{},
324
	);
325
326
	// Handle slugField separately since it has no default
327
	if (slugField) {
328
		builtMapping.slugField = slugField;
329
	}
330
331
	const frontmatter =
332
		Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
333
334
	return {
335
		...config,
336
		frontmatter,
337
	};
338
}
339
340
async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> {
341
	const pdsUrl = exitOnCancel(
342
		await text({
343
			message: "PDS URL (leave empty for default bsky.social):",
344
			initialValue: config.pdsUrl || "",
345
		}),
346
	);
347
348
	const identity = exitOnCancel(
349
		await text({
350
			message: "Identity/profile to use (leave empty for auto-detect):",
351
			initialValue: config.identity || "",
352
		}),
353
	);
354
355
	const ignoreInput = exitOnCancel(
356
		await text({
357
			message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):",
358
			initialValue: config.ignore?.join(", ") || "",
359
		}),
360
	);
361
362
	const removeIndexFromSlug = exitOnCancel(
363
		await confirm({
364
			message: "Remove /index or /_index suffix from paths?",
365
			initialValue: config.removeIndexFromSlug || false,
366
		}),
367
	);
368
369
	const stripDatePrefix = exitOnCancel(
370
		await confirm({
371
			message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?",
372
			initialValue: config.stripDatePrefix || false,
373
		}),
374
	);
375
376
	const textContentField = exitOnCancel(
377
		await text({
378
			message:
379
				"Frontmatter field for textContent (leave empty to use markdown body):",
380
			initialValue: config.textContentField || "",
381
		}),
382
	);
383
384
	// Parse ignore patterns
385
	const ignore = ignoreInput
386
		? ignoreInput
387
				.split(",")
388
				.map((p) => p.trim())
389
				.filter(Boolean)
390
		: undefined;
391
392
	return {
393
		...config,
394
		pdsUrl: pdsUrl || undefined,
395
		identity: identity || undefined,
396
		ignore: ignore && ignore.length > 0 ? ignore : undefined,
397
		removeIndexFromSlug: removeIndexFromSlug || undefined,
398
		stripDatePrefix: stripDatePrefix || undefined,
399
		textContentField: textContentField || undefined,
400
	};
401
}
402
403
async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> {
404
	const enabled = exitOnCancel(
405
		await confirm({
406
			message: "Enable automatic Bluesky posting when publishing?",
407
			initialValue: config.bluesky?.enabled || false,
408
		}),
409
	);
410
411
	if (!enabled) {
412
		return {
413
			...config,
414
			bluesky: undefined,
415
		};
416
	}
417
418
	const maxAgeDaysInput = exitOnCancel(
419
		await text({
420
			message: "Maximum age (in days) for posts to be shared on Bluesky:",
421
			initialValue: String(config.bluesky?.maxAgeDays || 7),
422
			validate: (value) => {
423
				if (!value) return "Please enter a number";
424
				const num = Number.parseInt(value, 10);
425
				if (Number.isNaN(num) || num < 1) {
426
					return "Please enter a positive number";
427
				}
428
			},
429
		}),
430
	);
431
432
	const maxAgeDays = parseInt(maxAgeDaysInput, 10);
433
434
	const bluesky: BlueskyConfig = {
435
		enabled: true,
436
		...(maxAgeDays !== 7 && { maxAgeDays }),
437
	};
438
439
	return {
440
		...config,
441
		bluesky,
442
	};
443
}
444
445
async function updatePublicationFlow(config: PublisherConfig): Promise<void> {
446
	// Load credentials
447
	let credentials = await loadCredentials(config.identity);
448
449
	if (!credentials) {
450
		const identities = await listAllCredentials();
451
		if (identities.length === 0) {
452
			log.error(
453
				"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
454
			);
455
			process.exit(1);
456
		}
457
458
		// Build labels with handles for OAuth sessions
459
		const options = await Promise.all(
460
			identities.map(async (cred) => {
461
				if (cred.type === "oauth") {
462
					const handle = await getOAuthHandle(cred.id);
463
					return {
464
						value: cred.id,
465
						label: `${handle || cred.id} (OAuth)`,
466
					};
467
				}
468
				return {
469
					value: cred.id,
470
					label: `${cred.id} (App Password)`,
471
				};
472
			}),
473
		);
474
475
		log.info("Multiple identities found. Select one to use:");
476
		const selected = exitOnCancel(
477
			await select({
478
				message: "Identity:",
479
				options,
480
			}),
481
		);
482
483
		// Load the selected credentials
484
		const selectedCred = identities.find((c) => c.id === selected);
485
		if (selectedCred?.type === "oauth") {
486
			const session = await getOAuthSession(selected);
487
			if (session) {
488
				const handle = await getOAuthHandle(selected);
489
				credentials = {
490
					type: "oauth",
491
					did: selected,
492
					handle: handle || selected,
493
				};
494
			}
495
		} else {
496
			credentials = await getCredentials(selected);
497
		}
498
499
		if (!credentials) {
500
			log.error("Failed to load selected credentials.");
501
			process.exit(1);
502
		}
503
	}
504
505
	const s = spinner();
506
	s.start("Connecting to ATProto...");
507
508
	let agent: Awaited<ReturnType<typeof createAgent>>;
509
	try {
510
		agent = await createAgent(credentials);
511
		s.stop("Connected!");
512
	} catch (error) {
513
		s.stop("Failed to connect");
514
		log.error(`Failed to connect: ${error}`);
515
		process.exit(1);
516
	}
517
518
	// Fetch existing publication
519
	s.start("Fetching publication...");
520
	const publication = await getPublication(agent, config.publicationUri);
521
522
	if (!publication) {
523
		s.stop("Publication not found");
524
		log.error(`Could not find publication: ${config.publicationUri}`);
525
		process.exit(1);
526
	}
527
	s.stop("Publication loaded!");
528
529
	// Show current publication info
530
	const pubRecord = publication.value;
531
	const pubSummary = [
532
		`Name: ${pubRecord.name}`,
533
		`URL: ${pubRecord.url}`,
534
		pubRecord.description ? `Description: ${pubRecord.description}` : null,
535
		pubRecord.icon ? `Icon: (uploaded)` : null,
536
		`Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`,
537
		`Created: ${pubRecord.createdAt}`,
538
	]
539
		.filter(Boolean)
540
		.join("\n");
541
542
	note(pubSummary, "Current Publication");
543
544
	// Collect updates with pre-populated values
545
	const name = exitOnCancel(
546
		await text({
547
			message: "Publication name:",
548
			initialValue: pubRecord.name,
549
			validate: (value) => {
550
				if (!value) return "Publication name is required";
551
			},
552
		}),
553
	);
554
555
	const description = exitOnCancel(
556
		await text({
557
			message: "Publication description (leave empty to clear):",
558
			initialValue: pubRecord.description || "",
559
		}),
560
	);
561
562
	const url = exitOnCancel(
563
		await text({
564
			message: "Publication URL:",
565
			initialValue: pubRecord.url,
566
			validate: (value) => {
567
				if (!value) return "URL is required";
568
				try {
569
					new URL(value);
570
				} catch {
571
					return "Please enter a valid URL";
572
				}
573
			},
574
		}),
575
	);
576
577
	const iconPath = exitOnCancel(
578
		await text({
579
			message: "New icon path (leave empty to keep existing):",
580
			initialValue: "",
581
		}),
582
	);
583
584
	const showInDiscover = exitOnCancel(
585
		await confirm({
586
			message: "Show in Discover feed?",
587
			initialValue: pubRecord.preferences?.showInDiscover ?? true,
588
		}),
589
	);
590
591
	// Confirm before updating
592
	const shouldUpdate = exitOnCancel(
593
		await confirm({
594
			message: "Update publication on ATProto?",
595
			initialValue: true,
596
		}),
597
	);
598
599
	if (!shouldUpdate) {
600
		log.info("Update cancelled.");
601
		return;
602
	}
603
604
	// Perform update
605
	s.start("Updating publication...");
606
	try {
607
		await updatePublication(
608
			agent,
609
			config.publicationUri,
610
			{
611
				name,
612
				description,
613
				url,
614
				iconPath: iconPath || undefined,
615
				showInDiscover,
616
			},
617
			pubRecord,
618
		);
619
		s.stop("Publication updated!");
620
	} catch (error) {
621
		s.stop("Failed to update publication");
622
		log.error(`Failed to update: ${error}`);
623
		process.exit(1);
624
	}
625
}