A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at v0.1.0 319 lines 8.7 kB view raw
1import { command } from "cmd-ts"; 2import { 3 intro, 4 outro, 5 note, 6 text, 7 confirm, 8 select, 9 spinner, 10 log, 11 group, 12} from "@clack/prompts"; 13import * as path from "path"; 14import { findConfig, generateConfigTemplate } from "../lib/config"; 15import { loadCredentials } from "../lib/credentials"; 16import { createAgent, createPublication } from "../lib/atproto"; 17import type { FrontmatterMapping } from "../lib/types"; 18 19const onCancel = () => { 20 outro("Setup cancelled"); 21 process.exit(0); 22}; 23 24export const initCommand = command({ 25 name: "init", 26 description: "Initialize a new publisher configuration", 27 args: {}, 28 handler: async () => { 29 intro("Sequoia Configuration Setup"); 30 31 // Check if config already exists 32 const existingConfig = await findConfig(); 33 if (existingConfig) { 34 const overwrite = await confirm({ 35 message: `Config already exists at ${existingConfig}. Overwrite?`, 36 initialValue: false, 37 }); 38 if (overwrite === Symbol.for("cancel")) { 39 onCancel(); 40 } 41 if (!overwrite) { 42 log.info("Keeping existing configuration"); 43 return; 44 } 45 } 46 47 note("Follow the prompts to build your config for publishing", "Setup"); 48 49 // Site configuration group 50 const siteConfig = await group( 51 { 52 siteUrl: () => 53 text({ 54 message: "Site URL (canonical URL of your site):", 55 placeholder: "https://example.com", 56 validate: (value) => { 57 if (!value) return "Site URL is required"; 58 try { 59 new URL(value); 60 } catch { 61 return "Please enter a valid URL"; 62 } 63 }, 64 }), 65 contentDir: () => 66 text({ 67 message: "Content directory:", 68 placeholder: "./src/content/blog", 69 }), 70 imagesDir: () => 71 text({ 72 message: "Cover images directory (leave empty to skip):", 73 placeholder: "./src/assets", 74 }), 75 publicDir: () => 76 text({ 77 message: "Public/static directory (for .well-known files):", 78 placeholder: "./public", 79 }), 80 outputDir: () => 81 text({ 82 message: "Build output directory (for link tag injection):", 83 placeholder: "./dist", 84 }), 85 pathPrefix: () => 86 text({ 87 message: "URL path prefix for posts:", 88 placeholder: "/posts, /blog, /articles, etc.", 89 }), 90 }, 91 { onCancel }, 92 ); 93 94 log.info( 95 "Configure your frontmatter field mappings (press Enter to use defaults):", 96 ); 97 98 // Frontmatter mapping group 99 const frontmatterConfig = await group( 100 { 101 titleField: () => 102 text({ 103 message: "Field name for title:", 104 defaultValue: "title", 105 placeholder: "title", 106 }), 107 descField: () => 108 text({ 109 message: "Field name for description:", 110 defaultValue: "description", 111 placeholder: "description", 112 }), 113 dateField: () => 114 text({ 115 message: "Field name for publish date:", 116 defaultValue: "publishDate", 117 placeholder: "publishDate, pubDate, date, etc.", 118 }), 119 coverField: () => 120 text({ 121 message: "Field name for cover image:", 122 defaultValue: "ogImage", 123 placeholder: "ogImage, coverImage, image, hero, etc.", 124 }), 125 tagsField: () => 126 text({ 127 message: "Field name for tags:", 128 defaultValue: "tags", 129 placeholder: "tags, categories, keywords, etc.", 130 }), 131 }, 132 { onCancel }, 133 ); 134 135 // Build frontmatter mapping object 136 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ 137 ["title", frontmatterConfig.titleField, "title"], 138 ["description", frontmatterConfig.descField, "description"], 139 ["publishDate", frontmatterConfig.dateField, "publishDate"], 140 ["coverImage", frontmatterConfig.coverField, "ogImage"], 141 ["tags", frontmatterConfig.tagsField, "tags"], 142 ]; 143 144 const builtMapping = fieldMappings.reduce<FrontmatterMapping>( 145 (acc, [key, value, defaultValue]) => { 146 if (value !== defaultValue) { 147 acc[key] = value; 148 } 149 return acc; 150 }, 151 {}, 152 ); 153 154 // Only keep frontmatterMapping if it has any custom fields 155 const frontmatterMapping = 156 Object.keys(builtMapping).length > 0 ? builtMapping : undefined; 157 158 // Publication setup 159 const publicationChoice = await select({ 160 message: "Publication setup:", 161 options: [ 162 { label: "Create a new publication", value: "create" }, 163 { label: "Use an existing publication AT URI", value: "existing" }, 164 ], 165 }); 166 167 if (publicationChoice === Symbol.for("cancel")) { 168 onCancel(); 169 } 170 171 let publicationUri: string; 172 const credentials = await loadCredentials(); 173 174 if (publicationChoice === "create") { 175 // Need credentials to create a publication 176 if (!credentials) { 177 log.error( 178 "You must authenticate first. Run 'sequoia auth' before creating a publication.", 179 ); 180 process.exit(1); 181 } 182 183 const s = spinner(); 184 s.start("Connecting to ATProto..."); 185 let agent; 186 try { 187 agent = await createAgent(credentials); 188 s.stop("Connected!"); 189 } catch (error) { 190 s.stop("Failed to connect"); 191 log.error( 192 "Failed to connect. Check your credentials with 'sequoia auth'.", 193 ); 194 process.exit(1); 195 } 196 197 const publicationConfig = await group( 198 { 199 name: () => 200 text({ 201 message: "Publication name:", 202 placeholder: "My Blog", 203 validate: (value) => { 204 if (!value) return "Publication name is required"; 205 }, 206 }), 207 description: () => 208 text({ 209 message: "Publication description (optional):", 210 placeholder: "A blog about...", 211 }), 212 iconPath: () => 213 text({ 214 message: "Icon image path (leave empty to skip):", 215 placeholder: "./public/favicon.png", 216 }), 217 showInDiscover: () => 218 confirm({ 219 message: "Show in Discover feed?", 220 initialValue: true, 221 }), 222 }, 223 { onCancel }, 224 ); 225 226 s.start("Creating publication..."); 227 try { 228 publicationUri = await createPublication(agent, { 229 url: siteConfig.siteUrl, 230 name: publicationConfig.name, 231 description: publicationConfig.description || undefined, 232 iconPath: publicationConfig.iconPath || undefined, 233 showInDiscover: publicationConfig.showInDiscover, 234 }); 235 s.stop(`Publication created: ${publicationUri}`); 236 } catch (error) { 237 s.stop("Failed to create publication"); 238 log.error(`Failed to create publication: ${error}`); 239 process.exit(1); 240 } 241 } else { 242 const uri = await text({ 243 message: "Publication AT URI:", 244 placeholder: "at://did:plc:.../site.standard.publication/...", 245 validate: (value) => { 246 if (!value) return "Publication URI is required"; 247 }, 248 }); 249 250 if (uri === Symbol.for("cancel")) { 251 onCancel(); 252 } 253 publicationUri = uri as string; 254 } 255 256 // Get PDS URL from credentials (already loaded earlier) 257 const pdsUrl = credentials?.pdsUrl; 258 259 // Generate config file 260 const configContent = generateConfigTemplate({ 261 siteUrl: siteConfig.siteUrl, 262 contentDir: siteConfig.contentDir || "./content", 263 imagesDir: siteConfig.imagesDir || undefined, 264 publicDir: siteConfig.publicDir || "./public", 265 outputDir: siteConfig.outputDir || "./dist", 266 pathPrefix: siteConfig.pathPrefix || "/posts", 267 publicationUri, 268 pdsUrl, 269 frontmatter: frontmatterMapping, 270 }); 271 272 const configPath = path.join(process.cwd(), "sequoia.json"); 273 await Bun.write(configPath, configContent); 274 275 log.success(`Configuration saved to ${configPath}`); 276 277 // Create .well-known/site.standard.publication file 278 const publicDir = siteConfig.publicDir || "./public"; 279 const resolvedPublicDir = path.isAbsolute(publicDir) 280 ? publicDir 281 : path.join(process.cwd(), publicDir); 282 const wellKnownDir = path.join(resolvedPublicDir, ".well-known"); 283 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication"); 284 285 // Ensure .well-known directory exists 286 await Bun.write(path.join(wellKnownDir, ".gitkeep"), ""); 287 await Bun.write(wellKnownPath, publicationUri); 288 289 log.success(`Created ${wellKnownPath}`); 290 291 // Update .gitignore 292 const gitignorePath = path.join(process.cwd(), ".gitignore"); 293 const gitignoreFile = Bun.file(gitignorePath); 294 const stateFilename = ".sequoia-state.json"; 295 296 if (await gitignoreFile.exists()) { 297 const gitignoreContent = await gitignoreFile.text(); 298 if (!gitignoreContent.includes(stateFilename)) { 299 await Bun.write( 300 gitignorePath, 301 gitignoreContent + `\n${stateFilename}\n`, 302 ); 303 log.info(`Added ${stateFilename} to .gitignore`); 304 } 305 } else { 306 await Bun.write(gitignorePath, `${stateFilename}\n`); 307 log.info(`Created .gitignore with ${stateFilename}`); 308 } 309 310 note( 311 "Next steps:\n" + 312 "1. Run 'sequoia publish --dry-run' to preview\n" + 313 "2. Run 'sequoia publish' to publish your content", 314 "Setup complete!", 315 ); 316 317 outro("Happy publishing!"); 318 }, 319});