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