This PR completes issue #6 by adding a new interactive update command, allowing users to easily update their sequoia.json config or their site.standard.publication record on their PDS.
+15
docs/docs/pages/cli-reference.mdx
+15
docs/docs/pages/cli-reference.mdx
···
79
79
--dry-run, -n - Preview what would be synced without making changes [optional]
80
80
--help, -h - show help [optional]
81
81
```
82
+
83
+
## `update`
84
+
85
+
```bash [Terminal]
86
+
sequoia update
87
+
> Update local config or ATProto publication record
88
+
89
+
FLAGS:
90
+
--help, -h - show help [optional]
91
+
```
92
+
93
+
Interactive command to modify your existing configuration. Choose between:
94
+
95
+
- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings
96
+
- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility
+569
packages/cli/src/commands/update.ts
+569
packages/cli/src/commands/update.ts
···
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
+
}
+2
packages/cli/src/index.ts
+2
packages/cli/src/index.ts
···
7
7
import { loginCommand } from "./commands/login";
8
8
import { publishCommand } from "./commands/publish";
9
9
import { syncCommand } from "./commands/sync";
10
+
import { updateCommand } from "./commands/update";
10
11
11
12
const app = subcommands({
12
13
name: "sequoia",
···
42
43
login: loginCommand,
43
44
publish: publishCommand,
44
45
sync: syncCommand,
46
+
update: updateCommand,
45
47
},
46
48
});
47
49
+97
packages/cli/src/lib/atproto.ts
+97
packages/cli/src/lib/atproto.ts
···
8
8
BlobObject,
9
9
BlogPost,
10
10
Credentials,
11
+
PublicationRecord,
11
12
PublisherConfig,
12
13
StrongRef,
13
14
} from "./types";
···
442
443
return response.data.uri;
443
444
}
444
445
446
+
export interface GetPublicationResult {
447
+
uri: string;
448
+
cid: string;
449
+
value: PublicationRecord;
450
+
}
451
+
452
+
export async function getPublication(
453
+
agent: Agent,
454
+
publicationUri: string,
455
+
): Promise<GetPublicationResult | null> {
456
+
const parsed = parseAtUri(publicationUri);
457
+
if (!parsed) {
458
+
return null;
459
+
}
460
+
461
+
try {
462
+
const response = await agent.com.atproto.repo.getRecord({
463
+
repo: parsed.did,
464
+
collection: parsed.collection,
465
+
rkey: parsed.rkey,
466
+
});
467
+
468
+
return {
469
+
uri: publicationUri,
470
+
cid: response.data.cid!,
471
+
value: response.data.value as unknown as PublicationRecord,
472
+
};
473
+
} catch {
474
+
return null;
475
+
}
476
+
}
477
+
478
+
export interface UpdatePublicationOptions {
479
+
url?: string;
480
+
name?: string;
481
+
description?: string;
482
+
iconPath?: string;
483
+
showInDiscover?: boolean;
484
+
}
485
+
486
+
export async function updatePublication(
487
+
agent: Agent,
488
+
publicationUri: string,
489
+
options: UpdatePublicationOptions,
490
+
existingRecord: PublicationRecord,
491
+
): Promise<void> {
492
+
const parsed = parseAtUri(publicationUri);
493
+
if (!parsed) {
494
+
throw new Error(`Invalid publication URI: ${publicationUri}`);
495
+
}
496
+
497
+
// Build updated record, preserving createdAt and $type
498
+
const record: Record<string, unknown> = {
499
+
$type: existingRecord.$type,
500
+
url: options.url ?? existingRecord.url,
501
+
name: options.name ?? existingRecord.name,
502
+
createdAt: existingRecord.createdAt,
503
+
};
504
+
505
+
// Handle description - can be cleared with empty string
506
+
if (options.description !== undefined) {
507
+
if (options.description) {
508
+
record.description = options.description;
509
+
}
510
+
// If empty string, don't include description (clears it)
511
+
} else if (existingRecord.description) {
512
+
record.description = existingRecord.description;
513
+
}
514
+
515
+
// Handle icon - upload new if provided, otherwise keep existing
516
+
if (options.iconPath) {
517
+
const icon = await uploadImage(agent, options.iconPath);
518
+
if (icon) {
519
+
record.icon = icon;
520
+
}
521
+
} else if (existingRecord.icon) {
522
+
record.icon = existingRecord.icon;
523
+
}
524
+
525
+
// Handle preferences
526
+
if (options.showInDiscover !== undefined) {
527
+
record.preferences = {
528
+
showInDiscover: options.showInDiscover,
529
+
};
530
+
} else if (existingRecord.preferences) {
531
+
record.preferences = existingRecord.preferences;
532
+
}
533
+
534
+
await agent.com.atproto.repo.putRecord({
535
+
repo: parsed.did,
536
+
collection: parsed.collection,
537
+
rkey: parsed.rkey,
538
+
record,
539
+
});
540
+
}
541
+
445
542
// --- Bluesky Post Creation ---
446
543
447
544
export interface CreateBlueskyPostOptions {
+5
-1
packages/cli/src/lib/markdown.ts
+5
-1
packages/cli/src/lib/markdown.ts
···
186
186
rawFrontmatter: Record<string, unknown>,
187
187
options: SlugOptions = {},
188
188
): string {
189
-
const { slugField, removeIndexFromSlug = false, stripDatePrefix = false } = options;
189
+
const {
190
+
slugField,
191
+
removeIndexFromSlug = false,
192
+
stripDatePrefix = false,
193
+
} = options;
190
194
191
195
let slug: string;
192
196
History
1 round
0 comments
stevedylan.dev
submitted
#0
1 commit
expand
collapse
feat: add
update command
- Adds interactive command to update either the sequoia.json config or
the atproto publication record
1/1 success
expand
collapse
expand 0 comments
pull request successfully merged