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