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