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