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