A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
58
fork

Configure Feed

Select the types of activity you want to include in your feed.

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});