A CLI for publishing standard.site documents to ATProto
at main 399 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 publishContent: () => 102 confirm({ 103 message: "Publish the post content on the standard.site document?", 104 initialValue: true, 105 }), 106 }, 107 { onCancel }, 108 ); 109 110 log.info( 111 "Configure your frontmatter field mappings (press Enter to use defaults):", 112 ); 113 114 // Frontmatter mapping group 115 const frontmatterConfig = await group( 116 { 117 titleField: () => 118 text({ 119 message: "Field name for title:", 120 defaultValue: "title", 121 placeholder: "title", 122 }), 123 descField: () => 124 text({ 125 message: "Field name for description:", 126 defaultValue: "description", 127 placeholder: "description", 128 }), 129 dateField: () => 130 text({ 131 message: "Field name for publish date:", 132 defaultValue: "publishDate", 133 placeholder: "publishDate, pubDate, date, etc.", 134 }), 135 coverField: () => 136 text({ 137 message: "Field name for cover image:", 138 defaultValue: "ogImage", 139 placeholder: "ogImage, coverImage, image, hero, etc.", 140 }), 141 tagsField: () => 142 text({ 143 message: "Field name for tags:", 144 defaultValue: "tags", 145 placeholder: "tags, categories, keywords, etc.", 146 }), 147 draftField: () => 148 text({ 149 message: "Field name for draft status:", 150 defaultValue: "draft", 151 placeholder: "draft, private, hidden, etc.", 152 }), 153 }, 154 { onCancel }, 155 ); 156 157 // Build frontmatter mapping object 158 const fieldMappings: Array<[keyof FrontmatterMapping, string, string]> = [ 159 ["title", frontmatterConfig.titleField, "title"], 160 ["description", frontmatterConfig.descField, "description"], 161 ["publishDate", frontmatterConfig.dateField, "publishDate"], 162 ["coverImage", frontmatterConfig.coverField, "ogImage"], 163 ["tags", frontmatterConfig.tagsField, "tags"], 164 ["draft", frontmatterConfig.draftField, "draft"], 165 ]; 166 167 const builtMapping = fieldMappings.reduce<FrontmatterMapping>( 168 (acc, [key, value, defaultValue]) => { 169 if (value !== defaultValue) { 170 acc[key] = value; 171 } 172 return acc; 173 }, 174 {}, 175 ); 176 177 // Only keep frontmatterMapping if it has any custom fields 178 const frontmatterMapping = 179 Object.keys(builtMapping).length > 0 ? builtMapping : undefined; 180 181 // Publication setup 182 const publicationChoice = await select({ 183 message: "Publication setup:", 184 options: [ 185 { label: "Create a new publication", value: "create" }, 186 { label: "Use an existing publication AT URI", value: "existing" }, 187 ], 188 }); 189 190 if (publicationChoice === Symbol.for("cancel")) { 191 onCancel(); 192 } 193 194 let publicationUri: string; 195 let credentials = await loadCredentials(); 196 197 if (publicationChoice === "create") { 198 // Need credentials to create a publication 199 if (!credentials) { 200 // Check if there are multiple identities - if so, prompt to select 201 const allCredentials = await listAllCredentials(); 202 if (allCredentials.length > 1) { 203 credentials = await selectCredential(allCredentials); 204 } else if (allCredentials.length === 1) { 205 // Single credential exists but couldn't be loaded - try to load it explicitly 206 credentials = await selectCredential(allCredentials); 207 } else { 208 log.error( 209 "You must authenticate first. Run 'sequoia login' (recommended) or 'sequoia auth' before creating a publication.", 210 ); 211 process.exit(1); 212 } 213 } 214 215 if (!credentials) { 216 log.error( 217 "Could not load credentials. Try running 'sequoia login' again to re-authenticate.", 218 ); 219 process.exit(1); 220 } 221 222 const s = spinner(); 223 s.start("Connecting to ATProto..."); 224 let agent: Awaited<ReturnType<typeof createAgent>> | undefined; 225 try { 226 agent = await createAgent(credentials); 227 s.stop("Connected!"); 228 } catch (_error) { 229 s.stop("Failed to connect"); 230 log.error( 231 "Failed to connect. Try re-authenticating with 'sequoia login' or 'sequoia auth'.", 232 ); 233 process.exit(1); 234 } 235 236 const publicationConfig = await group( 237 { 238 name: () => 239 text({ 240 message: "Publication name:", 241 placeholder: "My Blog", 242 validate: (value) => { 243 if (!value) return "Publication name is required"; 244 }, 245 }), 246 description: () => 247 text({ 248 message: "Publication description (optional):", 249 placeholder: "A blog about...", 250 }), 251 iconPath: () => 252 text({ 253 message: "Icon image path (leave empty to skip):", 254 placeholder: "./public/favicon.png", 255 }), 256 showInDiscover: () => 257 confirm({ 258 message: "Show in Discover feed?", 259 initialValue: true, 260 }), 261 }, 262 { onCancel }, 263 ); 264 265 s.start("Creating publication..."); 266 try { 267 publicationUri = await createPublication(agent, { 268 url: siteConfig.siteUrl, 269 name: publicationConfig.name, 270 description: publicationConfig.description || undefined, 271 iconPath: publicationConfig.iconPath || undefined, 272 showInDiscover: publicationConfig.showInDiscover, 273 }); 274 s.stop(`Publication created: ${publicationUri}`); 275 } catch (error) { 276 s.stop("Failed to create publication"); 277 log.error(`Failed to create publication: ${error}`); 278 process.exit(1); 279 } 280 } else { 281 const uri = await text({ 282 message: "Publication AT URI:", 283 placeholder: "at://did:plc:.../site.standard.publication/...", 284 validate: (value) => { 285 if (!value) return "Publication URI is required"; 286 }, 287 }); 288 289 if (uri === Symbol.for("cancel")) { 290 onCancel(); 291 } 292 publicationUri = uri as string; 293 } 294 295 // Bluesky posting configuration 296 const enableBluesky = await confirm({ 297 message: "Enable automatic Bluesky posting when publishing?", 298 initialValue: false, 299 }); 300 301 if (enableBluesky === Symbol.for("cancel")) { 302 onCancel(); 303 } 304 305 let blueskyConfig: BlueskyConfig | undefined; 306 if (enableBluesky) { 307 const maxAgeDaysInput = await text({ 308 message: "Maximum age (in days) for posts to be shared on Bluesky:", 309 defaultValue: "7", 310 placeholder: "7", 311 validate: (value) => { 312 if (!value) { 313 return "Please enter a number"; 314 } 315 const num = Number.parseInt(value, 10); 316 if (Number.isNaN(num) || num < 1) { 317 return "Please enter a positive number"; 318 } 319 }, 320 }); 321 322 if (maxAgeDaysInput === Symbol.for("cancel")) { 323 onCancel(); 324 } 325 326 const maxAgeDays = parseInt(maxAgeDaysInput as string, 10); 327 blueskyConfig = { 328 enabled: true, 329 ...(maxAgeDays !== 7 && { maxAgeDays }), 330 }; 331 } 332 333 // Get PDS URL from credentials (only available for app-password auth) 334 const pdsUrl = 335 credentials?.type === "app-password" ? credentials.pdsUrl : undefined; 336 337 // Generate config file 338 const configContent = generateConfigTemplate({ 339 siteUrl: siteConfig.siteUrl, 340 contentDir: siteConfig.contentDir || "./content", 341 imagesDir: siteConfig.imagesDir || undefined, 342 publicDir: siteConfig.publicDir || "./public", 343 outputDir: siteConfig.outputDir || "./dist", 344 pathPrefix: siteConfig.pathPrefix || "/posts", 345 publicationUri, 346 pdsUrl, 347 frontmatter: frontmatterMapping, 348 bluesky: blueskyConfig, 349 publishContent: siteConfig.publishContent, 350 }); 351 352 const configPath = path.join(process.cwd(), "sequoia.json"); 353 await fs.writeFile(configPath, configContent); 354 355 log.success(`Configuration saved to ${configPath}`); 356 357 // Create .well-known/site.standard.publication file 358 const publicDir = siteConfig.publicDir || "./public"; 359 const resolvedPublicDir = path.isAbsolute(publicDir) 360 ? publicDir 361 : path.join(process.cwd(), publicDir); 362 const wellKnownDir = path.join(resolvedPublicDir, ".well-known"); 363 const wellKnownPath = path.join(wellKnownDir, "site.standard.publication"); 364 365 // Ensure .well-known directory exists 366 await fs.mkdir(wellKnownDir, { recursive: true }); 367 await fs.writeFile(path.join(wellKnownDir, ".gitkeep"), ""); 368 await fs.writeFile(wellKnownPath, publicationUri); 369 370 log.success(`Created ${wellKnownPath}`); 371 372 // Update .gitignore 373 const gitignorePath = path.join(process.cwd(), ".gitignore"); 374 const stateFilename = ".sequoia-state.json"; 375 376 if (await fileExists(gitignorePath)) { 377 const gitignoreContent = await fs.readFile(gitignorePath, "utf-8"); 378 if (!gitignoreContent.includes(stateFilename)) { 379 await fs.writeFile( 380 gitignorePath, 381 `${gitignoreContent}\n${stateFilename}\n`, 382 ); 383 log.info(`Added ${stateFilename} to .gitignore`); 384 } 385 } else { 386 await fs.writeFile(gitignorePath, `${stateFilename}\n`); 387 log.info(`Created .gitignore with ${stateFilename}`); 388 } 389 390 note( 391 "Next steps:\n" + 392 "1. Run 'sequoia publish --dry-run' to preview\n" + 393 "2. Run 'sequoia publish' to publish your content", 394 "Setup complete!", 395 ); 396 397 outro("Happy publishing!"); 398 }, 399});