Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618import * 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 } from "../lib/config";import { loadCredentials, listAllCredentials, getCredentials,} from "../lib/credentials";import { getOAuthHandle, getOAuthSession } from "../lib/oauth-store";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<void> { // 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 = JSON.stringify(configUpdated, null, 2);
await fs.writeFile(configPath, configContent); log.success("Configuration saved!"); } else { log.info("Changes discarded."); }}
async function editSiteSettings( config: PublisherConfig,): Promise<PublisherConfig> { 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, };}
async function editDirectories( config: PublisherConfig,): Promise<PublisherConfig> { 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<PublisherConfig> { 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<FrontmatterMapping>( (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<PublisherConfig> { 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 publishContent = exitOnCancel( await confirm({ message: "Publish the post content on the standard.site document?", initialValue: config.publishContent ?? true, }), );
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, publishContent: publishContent ?? true, };}
async function editBluesky(config: PublisherConfig): Promise<PublisherConfig> { 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<void> { // Load credentials let credentials = await loadCredentials(config.identity);
if (!credentials) { const identities = await listAllCredentials(); if (identities.length === 0) { log.error( "No credentials found. Run 'sequoia login' or 'sequoia auth' first.", ); process.exit(1); }
// Build labels with handles for OAuth sessions const options = await Promise.all( identities.map(async (cred) => { if (cred.type === "oauth") { const handle = await getOAuthHandle(cred.id); return { value: cred.id, label: `${handle || cred.id} (OAuth)`, }; } return { value: cred.id, label: `${cred.id} (App Password)`, }; }), );
log.info("Multiple identities found. Select one to use:"); const selected = exitOnCancel( await select({ message: "Identity:", options, }), );
// Load the selected credentials const selectedCred = identities.find((c) => c.id === selected); if (selectedCred?.type === "oauth") { const session = await getOAuthSession(selected); if (session) { const handle = await getOAuthHandle(selected); credentials = { type: "oauth", did: selected, handle: handle || selected, }; } } else { credentials = await getCredentials(selected); }
if (!credentials) { log.error("Failed to load selected credentials."); process.exit(1); } }
const s = spinner(); s.start("Connecting to ATProto...");
let agent: Awaited<ReturnType<typeof createAgent>>; 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); }}