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
			textContentField: configUpdated.textContentField,
164
			bluesky: configUpdated.bluesky,
165
		});
166
167
		await fs.writeFile(configPath, configContent);
168
		log.success("Configuration saved!");
169
	} else {
170
		log.info("Changes discarded.");
171
	}
172
}
173
174
async function editSiteSettings(
175
	config: PublisherConfig,
176
): Promise<PublisherConfig> {
177
	const siteUrl = exitOnCancel(
178
		await text({
179
			message: "Site URL:",
180
			initialValue: config.siteUrl,
181
			validate: (value) => {
182
				if (!value) return "Site URL is required";
183
				try {
184
					new URL(value);
185
				} catch {
186
					return "Please enter a valid URL";
187
				}
188
			},
189
		}),
190
	);
191
192
	const pathPrefix = exitOnCancel(
193
		await text({
194
			message: "URL path prefix for posts:",
195
			initialValue: config.pathPrefix || "/posts",
196
		}),
197
	);
198
199
	return {
200
		...config,
201
		siteUrl,
202
		pathPrefix: pathPrefix || undefined,
203
	};
204
}
205
206
async function editDirectories(
207
	config: PublisherConfig,
208
): Promise<PublisherConfig> {
209
	const contentDir = exitOnCancel(
210
		await text({
211
			message: "Content directory:",
212
			initialValue: config.contentDir,
213
			validate: (value) => {
214
				if (!value) return "Content directory is required";
215
			},
216
		}),
217
	);
218
219
	const imagesDir = exitOnCancel(
220
		await text({
221
			message: "Cover images directory (leave empty to skip):",
222
			initialValue: config.imagesDir || "",
223
		}),
224
	);
225
226
	const publicDir = exitOnCancel(
227
		await text({
228
			message: "Public/static directory:",
229
			initialValue: config.publicDir || "./public",
230
		}),
231
	);
232
233
	const outputDir = exitOnCancel(
234
		await text({
235
			message: "Build output directory:",
236
			initialValue: config.outputDir || "./dist",
237
		}),
238
	);
239
240
	return {
241
		...config,
242
		contentDir,
243
		imagesDir: imagesDir || undefined,
244
		publicDir: publicDir || undefined,
245
		outputDir: outputDir || undefined,
246
	};
247
}
248
249
async function editFrontmatter(
250
	config: PublisherConfig,
251
): Promise<PublisherConfig> {
252
	const currentFrontmatter = config.frontmatter || {};
253
254
	log.info("Press Enter to keep current value, or type a new field name.");
255
256
	const titleField = exitOnCancel(
257
		await text({
258
			message: "Field name for title:",
259
			initialValue: currentFrontmatter.title || "title",
260
		}),
261
	);
262
263
	const descField = exitOnCancel(
264
		await text({
265
			message: "Field name for description:",
266
			initialValue: currentFrontmatter.description || "description",
267
		}),
268
	);
269
270
	const dateField = exitOnCancel(
271
		await text({
272
			message: "Field name for publish date:",
273
			initialValue: currentFrontmatter.publishDate || "publishDate",
274
		}),
275
	);
276
277
	const coverField = exitOnCancel(
278
		await text({
279
			message: "Field name for cover image:",
280
			initialValue: currentFrontmatter.coverImage || "ogImage",
281
		}),
282
	);
283
284
	const tagsField = exitOnCancel(
285
		await text({
286
			message: "Field name for tags:",
287
			initialValue: currentFrontmatter.tags || "tags",
288
		}),
289
	);
290
291
	const draftField = exitOnCancel(
292
		await text({
293
			message: "Field name for draft status:",
294
			initialValue: currentFrontmatter.draft || "draft",
295
		}),
296
	);
297
298
	const slugField = exitOnCancel(
299
		await text({
300
			message: "Field name for slug (leave empty to use filepath):",
301
			initialValue: currentFrontmatter.slugField || "",
302
		}),
303
	);
304
305
	// Build frontmatter mapping, only including non-default values
306
	const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [
307
		["title", titleField, "title"],
308
		["description", descField, "description"],
309
		["publishDate", dateField, "publishDate"],
310
		["coverImage", coverField, "ogImage"],
311
		["tags", tagsField, "tags"],
312
		["draft", draftField, "draft"],
313
	];
314
315
	const builtMapping = fieldMappings.reduce<FrontmatterMapping>(
316
		(acc, [key, value, defaultValue]) => {
317
			if (value !== defaultValue) {
318
				acc[key] = value;
319
			}
320
			return acc;
321
		},
322
		{},
323
	);
324
325
	// Handle slugField separately since it has no default
326
	if (slugField) {
327
		builtMapping.slugField = slugField;
328
	}
329
330
	const frontmatter =
331
		Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
332
333
	return {
334
		...config,
335
		frontmatter,
336
	};
337
}
338
339
async function editAdvanced(config: PublisherConfig): Promise<PublisherConfig> {
340
	const pdsUrl = exitOnCancel(
341
		await text({
342
			message: "PDS URL (leave empty for default bsky.social):",
343
			initialValue: config.pdsUrl || "",
344
		}),
345
	);
346
347
	const identity = exitOnCancel(
348
		await text({
349
			message: "Identity/profile to use (leave empty for auto-detect):",
350
			initialValue: config.identity || "",
351
		}),
352
	);
353
354
	const ignoreInput = exitOnCancel(
355
		await text({
356
			message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):",
357
			initialValue: config.ignore?.join(", ") || "",
358
		}),
359
	);
360
361
	const removeIndexFromSlug = exitOnCancel(
362
		await confirm({
363
			message: "Remove /index or /_index suffix from paths?",
364
			initialValue: config.removeIndexFromSlug || false,
365
		}),
366
	);
367
368
	const stripDatePrefix = exitOnCancel(
369
		await confirm({
370
			message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?",
371
			initialValue: config.stripDatePrefix || false,
372
		}),
373
	);
374
375
	const textContentField = exitOnCancel(
376
		await text({
377
			message:
378
				"Frontmatter field for textContent (leave empty to use markdown body):",
379
			initialValue: config.textContentField || "",
380
		}),
381
	);
382
383
	// Parse ignore patterns
384
	const ignore = ignoreInput
385
		? ignoreInput
386
				.split(",")
387
				.map((p) => p.trim())
388
				.filter(Boolean)
389
		: undefined;
390
391
	return {
392
		...config,
393
		pdsUrl: pdsUrl || undefined,
394
		identity: identity || undefined,
395
		ignore: ignore && ignore.length > 0 ? ignore : undefined,
396
		removeIndexFromSlug: removeIndexFromSlug || undefined,
397
		stripDatePrefix: stripDatePrefix || undefined,
398
		textContentField: textContentField || undefined,
399
	};
400
}
401
402
async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> {
403
	const enabled = exitOnCancel(
404
		await confirm({
405
			message: "Enable automatic Bluesky posting when publishing?",
406
			initialValue: config.bluesky?.enabled || false,
407
		}),
408
	);
409
410
	if (!enabled) {
411
		return {
412
			...config,
413
			bluesky: undefined,
414
		};
415
	}
416
417
	const maxAgeDaysInput = exitOnCancel(
418
		await text({
419
			message: "Maximum age (in days) for posts to be shared on Bluesky:",
420
			initialValue: String(config.bluesky?.maxAgeDays || 7),
421
			validate: (value) => {
422
				if (!value) return "Please enter a number";
423
				const num = Number.parseInt(value, 10);
424
				if (Number.isNaN(num) || num < 1) {
425
					return "Please enter a positive number";
426
				}
427
			},
428
		}),
429
	);
430
431
	const maxAgeDays = parseInt(maxAgeDaysInput, 10);
432
433
	const bluesky: BlueskyConfig = {
434
		enabled: true,
435
		...(maxAgeDays !== 7 && { maxAgeDays }),
436
	};
437
438
	return {
439
		...config,
440
		bluesky,
441
	};
442
}
443
444
async function updatePublicationFlow(config: PublisherConfig): Promise<void> {
445
	// Load credentials
446
	let credentials = await loadCredentials(config.identity);
447
448
	if (!credentials) {
449
		const identities = await listAllCredentials();
450
		if (identities.length === 0) {
451
			log.error(
452
				"No credentials found. Run 'sequoia login' or 'sequoia auth' first.",
453
			);
454
			process.exit(1);
455
		}
456
457
		// Build labels with handles for OAuth sessions
458
		const options = await Promise.all(
459
			identities.map(async (cred) => {
460
				if (cred.type === "oauth") {
461
					const handle = await getOAuthHandle(cred.id);
462
					return {
463
						value: cred.id,
464
						label: `${handle || cred.id} (OAuth)`,
465
					};
466
				}
467
				return {
468
					value: cred.id,
469
					label: `${cred.id} (App Password)`,
470
				};
471
			}),
472
		);
473
474
		log.info("Multiple identities found. Select one to use:");
475
		const selected = exitOnCancel(
476
			await select({
477
				message: "Identity:",
478
				options,
479
			}),
480
		);
481
482
		// Load the selected credentials
483
		const selectedCred = identities.find((c) => c.id === selected);
484
		if (selectedCred?.type === "oauth") {
485
			const session = await getOAuthSession(selected);
486
			if (session) {
487
				const handle = await getOAuthHandle(selected);
488
				credentials = {
489
					type: "oauth",
490
					did: selected,
491
					handle: handle || selected,
492
				};
493
			}
494
		} else {
495
			credentials = await getCredentials(selected);
496
		}
497
498
		if (!credentials) {
499
			log.error("Failed to load selected credentials.");
500
			process.exit(1);
501
		}
502
	}
503
504
	const s = spinner();
505
	s.start("Connecting to ATProto...");
506
507
	let agent: Awaited<ReturnType<typeof createAgent>>;
508
	try {
509
		agent = await createAgent(credentials);
510
		s.stop("Connected!");
511
	} catch (error) {
512
		s.stop("Failed to connect");
513
		log.error(`Failed to connect: ${error}`);
514
		process.exit(1);
515
	}
516
517
	// Fetch existing publication
518
	s.start("Fetching publication...");
519
	const publication = await getPublication(agent, config.publicationUri);
520
521
	if (!publication) {
522
		s.stop("Publication not found");
523
		log.error(`Could not find publication: ${config.publicationUri}`);
524
		process.exit(1);
525
	}
526
	s.stop("Publication loaded!");
527
528
	// Show current publication info
529
	const pubRecord = publication.value;
530
	const pubSummary = [
531
		`Name: ${pubRecord.name}`,
532
		`URL: ${pubRecord.url}`,
533
		pubRecord.description ? `Description: ${pubRecord.description}` : null,
534
		pubRecord.icon ? `Icon: (uploaded)` : null,
535
		`Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`,
536
		`Created: ${pubRecord.createdAt}`,
537
	]
538
		.filter(Boolean)
539
		.join("\n");
540
541
	note(pubSummary, "Current Publication");
542
543
	// Collect updates with pre-populated values
544
	const name = exitOnCancel(
545
		await text({
546
			message: "Publication name:",
547
			initialValue: pubRecord.name,
548
			validate: (value) => {
549
				if (!value) return "Publication name is required";
550
			},
551
		}),
552
	);
553
554
	const description = exitOnCancel(
555
		await text({
556
			message: "Publication description (leave empty to clear):",
557
			initialValue: pubRecord.description || "",
558
		}),
559
	);
560
561
	const url = exitOnCancel(
562
		await text({
563
			message: "Publication URL:",
564
			initialValue: pubRecord.url,
565
			validate: (value) => {
566
				if (!value) return "URL is required";
567
				try {
568
					new URL(value);
569
				} catch {
570
					return "Please enter a valid URL";
571
				}
572
			},
573
		}),
574
	);
575
576
	const iconPath = exitOnCancel(
577
		await text({
578
			message: "New icon path (leave empty to keep existing):",
579
			initialValue: "",
580
		}),
581
	);
582
583
	const showInDiscover = exitOnCancel(
584
		await confirm({
585
			message: "Show in Discover feed?",
586
			initialValue: pubRecord.preferences?.showInDiscover ?? true,
587
		}),
588
	);
589
590
	// Confirm before updating
591
	const shouldUpdate = exitOnCancel(
592
		await confirm({
593
			message: "Update publication on ATProto?",
594
			initialValue: true,
595
		}),
596
	);
597
598
	if (!shouldUpdate) {
599
		log.info("Update cancelled.");
600
		return;
601
	}
602
603
	// Perform update
604
	s.start("Updating publication...");
605
	try {
606
		await updatePublication(
607
			agent,
608
			config.publicationUri,
609
			{
610
				name,
611
				description,
612
				url,
613
				iconPath: iconPath || undefined,
614
				showInDiscover,
615
			},
616
			pubRecord,
617
		);
618
		s.stop("Publication updated!");
619
	} catch (error) {
620
		s.stop("Failed to update publication");
621
		log.error(`Failed to update: ${error}`);
622
		process.exit(1);
623
	}
624
}