Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406import * as fs from "node:fs/promises";import { command } from "cmd-ts";import { intro, outro, note, text, confirm, select, spinner, log, group,} from "@clack/prompts";import * as path from "node:path";import { findConfig, generateConfigTemplate, DEFAULT_PUBLISHER_CONFIG,} from "../lib/config";import { loadCredentials, listAllCredentials } from "../lib/credentials";import { createAgent, createPublication } from "../lib/atproto";import { selectCredential } from "../lib/credential-select";import type { FrontmatterMapping, BlueskyConfig } from "../lib/types";
async function fileExists(filePath: string): Promise<boolean> { try { await fs.access(filePath); return true; } catch { return false; }}
const onCancel = () => { outro("Setup cancelled"); process.exit(0);};
export const initCommand = command({ name: "init", description: "Initialize a new publisher configuration", args: {}, handler: async () => { intro("Sequoia Configuration Setup");
// Check if config already exists const existingConfig = await findConfig(); if (existingConfig) { const overwrite = await confirm({ message: `Config already exists at ${existingConfig}. Overwrite?`, initialValue: false, }); if (overwrite === Symbol.for("cancel")) { onCancel(); } if (!overwrite) { log.info("Keeping existing configuration"); return; } }
note("Follow the prompts to build your config for publishing", "Setup");
// Site configuration group const siteConfig = await group( { siteUrl: () => text({ message: "Site URL (canonical URL of your site):", placeholder: "https://example.com", validate: (value) => { if (!value) return "Site URL is required"; try { new URL(value); } catch { return "Please enter a valid URL"; } }, }), contentDir: () => text({ message: "Content directory:", placeholder: "./src/content/blog", }), imagesDir: () => text({ message: "Cover images directory (leave empty to skip):", placeholder: "./src/assets", }), publicDir: () => text({ message: "Public/static directory (for .well-known files):", placeholder: "./public", }), outputDir: () => text({ message: "Build output directory (for link tag injection):", placeholder: "./dist", }), pathPrefix: () => text({ message: "URL path prefix for posts:", placeholder: "/posts, /blog, /articles, etc.", }), publishContent: () => confirm({ message: "Publish the post content on the standard.site document?", initialValue: true, }), }, { onCancel }, );
log.info( "Configure your frontmatter field mappings (press Enter to use defaults):", );
// Frontmatter mapping group const frontmatterConfig = await group( { titleField: () => text({ message: "Field name for title:", defaultValue: "title", placeholder: "title", }), descField: () => text({ message: "Field name for description:", defaultValue: "description", placeholder: "description", }), dateField: () => text({ message: "Field name for publish date:", defaultValue: "publishDate", placeholder: "publishDate, pubDate, date, etc.", }), coverField: () => text({ message: "Field name for cover image:", defaultValue: "ogImage", placeholder: "ogImage, coverImage, image, hero, etc.", }), tagsField: () => text({ message: "Field name for tags:", defaultValue: "tags", placeholder: "tags, categories, keywords, etc.", }), draftField: () => text({ message: "Field name for draft status:", defaultValue: "draft", placeholder: "draft, private, hidden, etc.", }), }, { onCancel }, );
// Build frontmatter mapping object const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ ["title", frontmatterConfig.titleField, "title"], ["description", frontmatterConfig.descField, "description"], ["publishDate", frontmatterConfig.dateField, "publishDate"], ["coverImage", frontmatterConfig.coverField, "ogImage"], ["tags", frontmatterConfig.tagsField, "tags"], ["draft", frontmatterConfig.draftField, "draft"], ];
const builtMapping = fieldMappings.reduce<FrontmatterMapping>( (acc, [key, value, defaultValue]) => { if (value !== defaultValue) { acc[key] = value; } return acc; }, {}, );
// Only keep frontmatterMapping if it has any custom fields const frontmatterMapping = Object.keys(builtMapping).length > 0 ? builtMapping : undefined;
// Publication setup const publicationChoice = await select({ message: "Publication setup:", options: [ { label: "Create a new publication", value: "create" }, { label: "Use an existing publication AT URI", value: "existing" }, ], });
if (publicationChoice === Symbol.for("cancel")) { onCancel(); }
let publicationUri: string; let credentials = await loadCredentials();
if (publicationChoice === "create") { // Need credentials to create a publication if (!credentials) { // Check if there are multiple identities - if so, prompt to select const allCredentials = await listAllCredentials(); if (allCredentials.length > 1) { credentials = await selectCredential(allCredentials); } else if (allCredentials.length === 1) { // Single credential exists but couldn't be loaded - try to load it explicitly credentials = await selectCredential(allCredentials); } else { log.error( "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", ); process.exit(1); } }
if (!credentials) { log.error( "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", ); process.exit(1); }
const s = spinner(); s.start("Connecting to ATProto..."); let agent: Awaited<ReturnType<typeof createAgent>> | undefined; try { agent = await createAgent(credentials); s.stop("Connected!"); } catch (_error) { s.stop("Failed to connect"); log.error( "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", ); process.exit(1); }
const publicationConfig = await group( { name: () => text({ message: "Publication name:", placeholder: "My Blog", validate: (value) => { if (!value) return "Publication name is required"; }, }), description: () => text({ message: "Publication description (optional):", placeholder: "A blog about...", }), iconPath: () => text({ message: "Icon image path (leave empty to skip):", placeholder: "./public/favicon.png", }), showInDiscover: () => confirm({ message: "Show in Discover feed?", initialValue: true, }), }, { onCancel }, );
s.start("Creating publication..."); try { const publicationRef = await createPublication(agent, { url: siteConfig.siteUrl, name: publicationConfig.name, description: publicationConfig.description || undefined, iconPath: publicationConfig.iconPath || undefined, showInDiscover: publicationConfig.showInDiscover, }); publicationUri = publicationRef.uri; s.stop(`Publication created: ${publicationUri}`); } catch (error) { s.stop("Failed to create publication"); log.error(`Failed to create publication: ${error}`); process.exit(1); } } else { const uri = await text({ message: "Publication AT URI:", placeholder: "at://did:plc:.../site.standard.publication/...", validate: (value) => { if (!value) return "Publication URI is required"; }, });
if (uri === Symbol.for("cancel")) { onCancel(); } publicationUri = uri as string; }
// Bluesky posting configuration const enableBluesky = await confirm({ message: "Enable automatic Bluesky posting when publishing?", initialValue: false, });
if (enableBluesky === Symbol.for("cancel")) { onCancel(); }
let blueskyConfig: BlueskyConfig | undefined; if (enableBluesky) { const maxAgeDaysInput = await text({ message: "Maximum age (in days) for posts to be shared on Bluesky:", defaultValue: "7", placeholder: "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"; } }, });
if (maxAgeDaysInput === Symbol.for("cancel")) { onCancel(); }
const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); blueskyConfig = { enabled: true, ...(maxAgeDays !== 7 && { maxAgeDays }), }; }
// Get PDS URL from credentials (only available for app-password auth) const pdsUrl = credentials?.type === "app-password" ? credentials.pdsUrl : undefined;
// Generate config file const configContent = generateConfigTemplate({ siteUrl: siteConfig.siteUrl, contentDir: siteConfig.contentDir || "./content", imagesDir: siteConfig.imagesDir || undefined, publicDir: siteConfig.publicDir || DEFAULT_PUBLISHER_CONFIG.publicDir, outputDir: siteConfig.outputDir || DEFAULT_PUBLISHER_CONFIG.outputDir, pathPrefix: siteConfig.pathPrefix ?? DEFAULT_PUBLISHER_CONFIG.pathPrefix, publicationUri, pdsUrl, frontmatter: frontmatterMapping, bluesky: blueskyConfig, publishContent: siteConfig.publishContent, });
const configPath = path.join(process.cwd(), "sequoia.json"); await fs.writeFile(configPath, configContent);
log.success(`Configuration saved to ${configPath}`);
// Create .well-known/site.standard.publication file const publicDir = siteConfig.publicDir || DEFAULT_PUBLISHER_CONFIG.publicDir; const resolvedPublicDir = path.isAbsolute(publicDir) ? publicDir : path.join(process.cwd(), publicDir); const wellKnownDir = path.join(resolvedPublicDir, ".well-known"); const wellKnownPath = path.join(wellKnownDir, "site.standard.publication");
// Ensure .well-known directory exists await fs.mkdir(wellKnownDir, { recursive: true }); await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), ""); await fs.writeFile(wellKnownPath, publicationUri);
log.success(`Created ${wellKnownPath}`);
// Update .gitignore const gitignorePath = path.join(process.cwd(), ".gitignore"); const stateFilename = ".sequoia-state.json";
if (await fileExists(gitignorePath)) { const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); if (!gitignoreContent.includes(stateFilename)) { await fs.writeFile( gitignorePath, `${gitignoreContent}\n${stateFilename}\n`, ); log.info(`Added ${stateFilename} to .gitignore`); } } else { await fs.writeFile(gitignorePath, `${stateFilename}\n`); log.info(`Created .gitignore with ${stateFilename}`); }
note( "Next steps:\n" + "1. Run 'sequoia publish --dry-run' to preview\n" + "2. Run 'sequoia publish' to publish your content", "Setup complete!", );
outro("Happy publishing!"); },});