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