diff --git a/docs/docs/pages/cli-reference.mdx b/docs/docs/pages/cli-reference.mdx index d4b0ac8..d76b3ea 100644 --- a/docs/docs/pages/cli-reference.mdx +++ b/docs/docs/pages/cli-reference.mdx @@ -79,3 +79,18 @@ FLAGS: --dry-run, -n - Preview what would be synced without making changes [optional] --help, -h - show help [optional] ``` + +## `update` + +```bash [Terminal] +sequoia update +> Update local config or ATProto publication record + +FLAGS: + --help, -h - show help [optional] +``` + +Interactive command to modify your existing configuration. Choose between: + +- **Local configuration**: Edit `sequoia.json` settings including site URL, directory paths, frontmatter mappings, advanced options, and Bluesky settings +- **ATProto publication**: Update your publication record's name, description, URL, icon, and discover visibility diff --git a/packages/cli/src/commands/update.ts b/packages/cli/src/commands/update.ts new file mode 100644 index 0000000..26e6e28 --- /dev/null +++ b/packages/cli/src/commands/update.ts @@ -0,0 +1,569 @@ +import * as fs from "node:fs/promises"; +import { command } from "cmd-ts"; +import { + intro, + outro, + note, + text, + confirm, + select, + spinner, + log, +} from "@clack/prompts"; +import { findConfig, loadConfig, generateConfigTemplate } from "../lib/config"; +import { loadCredentials } from "../lib/credentials"; +import { createAgent, getPublication, updatePublication } from "../lib/atproto"; +import { exitOnCancel } from "../lib/prompts"; +import type { + PublisherConfig, + FrontmatterMapping, + BlueskyConfig, +} from "../lib/types"; + +export const updateCommand = command({ + name: "update", + description: "Update local config or ATProto publication record", + args: {}, + handler: async () => { + intro("Sequoia Update"); + + // Check if config exists + const configPath = await findConfig(); + if (!configPath) { + log.error("No configuration found. Run 'sequoia init' first."); + process.exit(1); + } + + const config = await loadConfig(configPath); + + // Ask what to update + const updateChoice = exitOnCancel( + await select({ + message: "What would you like to update?", + options: [ + { label: "Local configuration (sequoia.json)", value: "config" }, + { label: "ATProto publication record", value: "publication" }, + ], + }), + ); + + if (updateChoice === "config") { + await updateConfigFlow(config, configPath); + } else { + await updatePublicationFlow(config); + } + + outro("Update complete!"); + }, +}); + +async function updateConfigFlow( + config: PublisherConfig, + configPath: string, +): Promise { + // Show current config summary + const configSummary = [ + `Site URL: ${config.siteUrl}`, + `Content Dir: ${config.contentDir}`, + `Path Prefix: ${config.pathPrefix || "/posts"}`, + `Publication URI: ${config.publicationUri}`, + config.imagesDir ? `Images Dir: ${config.imagesDir}` : null, + config.outputDir ? `Output Dir: ${config.outputDir}` : null, + config.bluesky?.enabled ? `Bluesky: enabled` : null, + ] + .filter(Boolean) + .join("\n"); + + note(configSummary, "Current Configuration"); + + let configUpdated = { ...config }; + let editing = true; + + while (editing) { + const section = exitOnCancel( + await select({ + message: "Select a section to edit:", + options: [ + { label: "Site settings (siteUrl, pathPrefix)", value: "site" }, + { + label: + "Directory paths (contentDir, imagesDir, publicDir, outputDir)", + value: "directories", + }, + { + label: + "Frontmatter mappings (title, description, publishDate, etc.)", + value: "frontmatter", + }, + { + label: + "Advanced options (pdsUrl, identity, ignore, removeIndexFromSlug, etc.)", + value: "advanced", + }, + { + label: "Bluesky settings (enabled, maxAgeDays)", + value: "bluesky", + }, + { label: "Done editing", value: "done" }, + ], + }), + ); + + if (section === "done") { + editing = false; + continue; + } + + switch (section) { + case "site": + configUpdated = await editSiteSettings(configUpdated); + break; + case "directories": + configUpdated = await editDirectories(configUpdated); + break; + case "frontmatter": + configUpdated = await editFrontmatter(configUpdated); + break; + case "advanced": + configUpdated = await editAdvanced(configUpdated); + break; + case "bluesky": + configUpdated = await editBluesky(configUpdated); + break; + } + } + + // Confirm before saving + const shouldSave = exitOnCancel( + await confirm({ + message: "Save changes to sequoia.json?", + initialValue: true, + }), + ); + + if (shouldSave) { + const configContent = generateConfigTemplate({ + siteUrl: configUpdated.siteUrl, + contentDir: configUpdated.contentDir, + imagesDir: configUpdated.imagesDir, + publicDir: configUpdated.publicDir, + outputDir: configUpdated.outputDir, + pathPrefix: configUpdated.pathPrefix, + publicationUri: configUpdated.publicationUri, + pdsUrl: configUpdated.pdsUrl, + frontmatter: configUpdated.frontmatter, + ignore: configUpdated.ignore, + removeIndexFromSlug: configUpdated.removeIndexFromSlug, + stripDatePrefix: configUpdated.stripDatePrefix, + textContentField: configUpdated.textContentField, + bluesky: configUpdated.bluesky, + }); + + await fs.writeFile(configPath, configContent); + log.success("Configuration saved!"); + } else { + log.info("Changes discarded."); + } +} + +async function editSiteSettings( + config: PublisherConfig, +): Promise { + const siteUrl = exitOnCancel( + await text({ + message: "Site URL:", + initialValue: config.siteUrl, + validate: (value) => { + if (!value) return "Site URL is required"; + try { + new URL(value); + } catch { + return "Please enter a valid URL"; + } + }, + }), + ); + + const pathPrefix = exitOnCancel( + await text({ + message: "URL path prefix for posts:", + initialValue: config.pathPrefix || "/posts", + }), + ); + + return { + ...config, + siteUrl, + pathPrefix: pathPrefix || undefined, + }; +} + +async function editDirectories( + config: PublisherConfig, +): Promise { + const contentDir = exitOnCancel( + await text({ + message: "Content directory:", + initialValue: config.contentDir, + validate: (value) => { + if (!value) return "Content directory is required"; + }, + }), + ); + + const imagesDir = exitOnCancel( + await text({ + message: "Cover images directory (leave empty to skip):", + initialValue: config.imagesDir || "", + }), + ); + + const publicDir = exitOnCancel( + await text({ + message: "Public/static directory:", + initialValue: config.publicDir || "./public", + }), + ); + + const outputDir = exitOnCancel( + await text({ + message: "Build output directory:", + initialValue: config.outputDir || "./dist", + }), + ); + + return { + ...config, + contentDir, + imagesDir: imagesDir || undefined, + publicDir: publicDir || undefined, + outputDir: outputDir || undefined, + }; +} + +async function editFrontmatter( + config: PublisherConfig, +): Promise { + const currentFrontmatter = config.frontmatter || {}; + + log.info("Press Enter to keep current value, or type a new field name."); + + const titleField = exitOnCancel( + await text({ + message: "Field name for title:", + initialValue: currentFrontmatter.title || "title", + }), + ); + + const descField = exitOnCancel( + await text({ + message: "Field name for description:", + initialValue: currentFrontmatter.description || "description", + }), + ); + + const dateField = exitOnCancel( + await text({ + message: "Field name for publish date:", + initialValue: currentFrontmatter.publishDate || "publishDate", + }), + ); + + const coverField = exitOnCancel( + await text({ + message: "Field name for cover image:", + initialValue: currentFrontmatter.coverImage || "ogImage", + }), + ); + + const tagsField = exitOnCancel( + await text({ + message: "Field name for tags:", + initialValue: currentFrontmatter.tags || "tags", + }), + ); + + const draftField = exitOnCancel( + await text({ + message: "Field name for draft status:", + initialValue: currentFrontmatter.draft || "draft", + }), + ); + + const slugField = exitOnCancel( + await text({ + message: "Field name for slug (leave empty to use filepath):", + initialValue: currentFrontmatter.slugField || "", + }), + ); + + // Build frontmatter mapping, only including non-default values + const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ + ["title", titleField, "title"], + ["description", descField, "description"], + ["publishDate", dateField, "publishDate"], + ["coverImage", coverField, "ogImage"], + ["tags", tagsField, "tags"], + ["draft", draftField, "draft"], + ]; + + const builtMapping = fieldMappings.reduce( + (acc, [key, value, defaultValue]) => { + if (value !== defaultValue) { + acc[key] = value; + } + return acc; + }, + {}, + ); + + // Handle slugField separately since it has no default + if (slugField) { + builtMapping.slugField = slugField; + } + + const frontmatter = + Object.keys(builtMapping).length > 0 ? builtMapping : undefined; + + return { + ...config, + frontmatter, + }; +} + +async function editAdvanced(config: PublisherConfig): Promise { + const pdsUrl = exitOnCancel( + await text({ + message: "PDS URL (leave empty for default bsky.social):", + initialValue: config.pdsUrl || "", + }), + ); + + const identity = exitOnCancel( + await text({ + message: "Identity/profile to use (leave empty for auto-detect):", + initialValue: config.identity || "", + }), + ); + + const ignoreInput = exitOnCancel( + await text({ + message: "Ignore patterns (comma-separated, e.g., _index.md,drafts/**):", + initialValue: config.ignore?.join(", ") || "", + }), + ); + + const removeIndexFromSlug = exitOnCancel( + await confirm({ + message: "Remove /index or /_index suffix from paths?", + initialValue: config.removeIndexFromSlug || false, + }), + ); + + const stripDatePrefix = exitOnCancel( + await confirm({ + message: "Strip YYYY-MM-DD- prefix from filenames (Jekyll-style)?", + initialValue: config.stripDatePrefix || false, + }), + ); + + const textContentField = exitOnCancel( + await text({ + message: + "Frontmatter field for textContent (leave empty to use markdown body):", + initialValue: config.textContentField || "", + }), + ); + + // Parse ignore patterns + const ignore = ignoreInput + ? ignoreInput + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + : undefined; + + return { + ...config, + pdsUrl: pdsUrl || undefined, + identity: identity || undefined, + ignore: ignore && ignore.length > 0 ? ignore : undefined, + removeIndexFromSlug: removeIndexFromSlug || undefined, + stripDatePrefix: stripDatePrefix || undefined, + textContentField: textContentField || undefined, + }; +} + +async function editBluesky(config: PublisherConfig): Promise { + const enabled = exitOnCancel( + await confirm({ + message: "Enable automatic Bluesky posting when publishing?", + initialValue: config.bluesky?.enabled || false, + }), + ); + + if (!enabled) { + return { + ...config, + bluesky: undefined, + }; + } + + const maxAgeDaysInput = exitOnCancel( + await text({ + message: "Maximum age (in days) for posts to be shared on Bluesky:", + initialValue: String(config.bluesky?.maxAgeDays || 7), + validate: (value) => { + if (!value) return "Please enter a number"; + const num = Number.parseInt(value, 10); + if (Number.isNaN(num) || num < 1) { + return "Please enter a positive number"; + } + }, + }), + ); + + const maxAgeDays = parseInt(maxAgeDaysInput, 10); + + const bluesky: BlueskyConfig = { + enabled: true, + ...(maxAgeDays !== 7 && { maxAgeDays }), + }; + + return { + ...config, + bluesky, + }; +} + +async function updatePublicationFlow(config: PublisherConfig): Promise { + // Load credentials + const credentials = await loadCredentials(config.identity); + if (!credentials) { + log.error( + "No credentials found. Run 'sequoia auth' or 'sequoia login' first.", + ); + process.exit(1); + } + + const s = spinner(); + s.start("Connecting to ATProto..."); + + let agent: Awaited>; + try { + agent = await createAgent(credentials); + s.stop("Connected!"); + } catch (error) { + s.stop("Failed to connect"); + log.error(`Failed to connect: ${error}`); + process.exit(1); + } + + // Fetch existing publication + s.start("Fetching publication..."); + const publication = await getPublication(agent, config.publicationUri); + + if (!publication) { + s.stop("Publication not found"); + log.error(`Could not find publication: ${config.publicationUri}`); + process.exit(1); + } + s.stop("Publication loaded!"); + + // Show current publication info + const pubRecord = publication.value; + const pubSummary = [ + `Name: ${pubRecord.name}`, + `URL: ${pubRecord.url}`, + pubRecord.description ? `Description: ${pubRecord.description}` : null, + pubRecord.icon ? `Icon: (uploaded)` : null, + `Show in Discover: ${pubRecord.preferences?.showInDiscover ?? true}`, + `Created: ${pubRecord.createdAt}`, + ] + .filter(Boolean) + .join("\n"); + + note(pubSummary, "Current Publication"); + + // Collect updates with pre-populated values + const name = exitOnCancel( + await text({ + message: "Publication name:", + initialValue: pubRecord.name, + validate: (value) => { + if (!value) return "Publication name is required"; + }, + }), + ); + + const description = exitOnCancel( + await text({ + message: "Publication description (leave empty to clear):", + initialValue: pubRecord.description || "", + }), + ); + + const url = exitOnCancel( + await text({ + message: "Publication URL:", + initialValue: pubRecord.url, + validate: (value) => { + if (!value) return "URL is required"; + try { + new URL(value); + } catch { + return "Please enter a valid URL"; + } + }, + }), + ); + + const iconPath = exitOnCancel( + await text({ + message: "New icon path (leave empty to keep existing):", + initialValue: "", + }), + ); + + const showInDiscover = exitOnCancel( + await confirm({ + message: "Show in Discover feed?", + initialValue: pubRecord.preferences?.showInDiscover ?? true, + }), + ); + + // Confirm before updating + const shouldUpdate = exitOnCancel( + await confirm({ + message: "Update publication on ATProto?", + initialValue: true, + }), + ); + + if (!shouldUpdate) { + log.info("Update cancelled."); + return; + } + + // Perform update + s.start("Updating publication..."); + try { + await updatePublication( + agent, + config.publicationUri, + { + name, + description, + url, + iconPath: iconPath || undefined, + showInDiscover, + }, + pubRecord, + ); + s.stop("Publication updated!"); + } catch (error) { + s.stop("Failed to update publication"); + log.error(`Failed to update: ${error}`); + process.exit(1); + } +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 45be9c6..48f2954 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,6 +7,7 @@ import { injectCommand } from "./commands/inject"; import { loginCommand } from "./commands/login"; import { publishCommand } from "./commands/publish"; import { syncCommand } from "./commands/sync"; +import { updateCommand } from "./commands/update"; const app = subcommands({ name: "sequoia", @@ -42,6 +43,7 @@ Publish evergreen content to the ATmosphere login: loginCommand, publish: publishCommand, sync: syncCommand, + update: updateCommand, }, }); diff --git a/packages/cli/src/lib/atproto.ts b/packages/cli/src/lib/atproto.ts index 7149ef5..ca82f72 100644 --- a/packages/cli/src/lib/atproto.ts +++ b/packages/cli/src/lib/atproto.ts @@ -8,6 +8,7 @@ import type { BlobObject, BlogPost, Credentials, + PublicationRecord, PublisherConfig, StrongRef, } from "./types"; @@ -442,6 +443,102 @@ export async function createPublication( return response.data.uri; } +export interface GetPublicationResult { + uri: string; + cid: string; + value: PublicationRecord; +} + +export async function getPublication( + agent: Agent, + publicationUri: string, +): Promise { + const parsed = parseAtUri(publicationUri); + if (!parsed) { + return null; + } + + try { + const response = await agent.com.atproto.repo.getRecord({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + }); + + return { + uri: publicationUri, + cid: response.data.cid!, + value: response.data.value as unknown as PublicationRecord, + }; + } catch { + return null; + } +} + +export interface UpdatePublicationOptions { + url?: string; + name?: string; + description?: string; + iconPath?: string; + showInDiscover?: boolean; +} + +export async function updatePublication( + agent: Agent, + publicationUri: string, + options: UpdatePublicationOptions, + existingRecord: PublicationRecord, +): Promise { + const parsed = parseAtUri(publicationUri); + if (!parsed) { + throw new Error(`Invalid publication URI: ${publicationUri}`); + } + + // Build updated record, preserving createdAt and $type + const record: Record = { + $type: existingRecord.$type, + url: options.url ?? existingRecord.url, + name: options.name ?? existingRecord.name, + createdAt: existingRecord.createdAt, + }; + + // Handle description - can be cleared with empty string + if (options.description !== undefined) { + if (options.description) { + record.description = options.description; + } + // If empty string, don't include description (clears it) + } else if (existingRecord.description) { + record.description = existingRecord.description; + } + + // Handle icon - upload new if provided, otherwise keep existing + if (options.iconPath) { + const icon = await uploadImage(agent, options.iconPath); + if (icon) { + record.icon = icon; + } + } else if (existingRecord.icon) { + record.icon = existingRecord.icon; + } + + // Handle preferences + if (options.showInDiscover !== undefined) { + record.preferences = { + showInDiscover: options.showInDiscover, + }; + } else if (existingRecord.preferences) { + record.preferences = existingRecord.preferences; + } + + await agent.com.atproto.repo.putRecord({ + repo: parsed.did, + collection: parsed.collection, + rkey: parsed.rkey, + record, + }); +} + // --- Bluesky Post Creation --- export interface CreateBlueskyPostOptions { diff --git a/packages/cli/src/lib/markdown.ts b/packages/cli/src/lib/markdown.ts index e7d3a6f..dc257b6 100644 --- a/packages/cli/src/lib/markdown.ts +++ b/packages/cli/src/lib/markdown.ts @@ -186,7 +186,11 @@ export function getSlugFromOptions( rawFrontmatter: Record, options: SlugOptions = {}, ): string { - const { slugField, removeIndexFromSlug = false, stripDatePrefix = false } = options; + const { + slugField, + removeIndexFromSlug = false, + stripDatePrefix = false, + } = options; let slug: string;