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