A CLI for publishing standard.site documents to ATProto
0
fork

Configure Feed

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

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