Highly ambitious ATProtocol AppView service and sdks
at main 519 lines 17 kB view raw
1import { parseArgs } from "@std/cli/parse-args"; 2import { join, dirname } from "@std/path"; 3import { ensureDir } from "@std/fs"; 4import { cyan, green, bold, dim } from "@std/fmt/colors"; 5import { logger } from "../utils/logger.ts"; 6import { getTemplatesForName, AVAILABLE_TEMPLATES } from "../templates/embedded.ts"; 7import { generateSliceName, generateDomain } from "../utils/name_generator.ts"; 8import { createAuthenticatedClient } from "../utils/client.ts"; 9import { ConfigManager } from "../auth/config.ts"; 10import { pullCommand } from "./lexicon/pull.ts"; 11import { pushCommand } from "./lexicon/push.ts"; 12import { codegenCommand } from "./codegen.ts"; 13import { dasherize } from "../utils/strings.ts"; 14import { 15 SYSTEM_SLICE_URI, 16 REFERENCE_SLICE_URI, 17 DEFAULT_API_URL, 18 DEFAULT_AIP_BASE_URL, 19} from "../utils/constants.ts"; 20 21export async function initCommand(args: string[], _globalArgs: unknown) { 22 const parsed = parseArgs(args, { 23 string: ["name", "template"], 24 boolean: ["help"], 25 alias: { 26 n: "name", 27 h: "help", 28 t: "template", 29 }, 30 default: { 31 template: "deno-ssr", 32 }, 33 }); 34 35 if (parsed.help) { 36 showInitHelp(); 37 return; 38 } 39 40 // Validate template name 41 const templateName = parsed.template as string; 42 if (!AVAILABLE_TEMPLATES.includes(templateName)) { 43 logger.error(`Invalid template: ${templateName}`); 44 logger.info(`Available templates: ${AVAILABLE_TEMPLATES.join(", ")}`); 45 Deno.exit(1); 46 } 47 48 // Check if we're inside an existing project 49 const currentDir = Deno.cwd(); 50 const projectIndicators = ["deno.json", "slices.json", ".git"]; 51 52 for (const indicator of projectIndicators) { 53 try { 54 await Deno.stat(join(currentDir, indicator)); 55 logger.error( 56 `Cannot initialize a new project here - found ${indicator} in current directory.` 57 ); 58 logger.info( 59 "Please run this command from outside your project directory." 60 ); 61 Deno.exit(1); 62 } catch { 63 // File doesn't exist, which is what we want 64 } 65 } 66 67 let projectName = parsed.name || (parsed._[0] as string); 68 let wasNameGenerated = false; 69 70 // If no project name provided, generate a random one 71 if (!projectName) { 72 projectName = generateSliceName(); 73 wasNameGenerated = true; 74 75 console.log( 76 `\n${cyan("📦 No project name provided, generated one for you:")}` 77 ); 78 console.log(` Project name: ${bold(projectName)}`); 79 80 // Ask for confirmation 81 const proceed = confirm("\nDo you want to proceed with this name?"); 82 if (!proceed) { 83 logger.info( 84 "Initialization cancelled. Run 'slices init <project-name>' to specify your own name." 85 ); 86 return; 87 } 88 } else { 89 // Dasherize user-provided name 90 projectName = dasherize(projectName); 91 } 92 93 // Validate project name 94 if (!/^[a-zA-Z0-9_-]+$/.test(projectName)) { 95 logger.error( 96 "Project name can only contain letters, numbers, hyphens, and underscores" 97 ); 98 Deno.exit(1); 99 } 100 101 const targetDir = join(Deno.cwd(), projectName); 102 103 // Check if directory already exists 104 try { 105 const stat = await Deno.stat(targetDir); 106 if (stat.isDirectory) { 107 logger.error(`Directory '${projectName}' already exists`); 108 Deno.exit(1); 109 } 110 } catch { 111 // Directory doesn't exist, which is what we want 112 } 113 114 logger.info(`Creating project: ${projectName}`); 115 116 // Try to create a slice with the same name 117 let sliceUri: string | undefined; 118 let sliceDomain: string | undefined; 119 let oauthClientId: string | undefined; 120 let oauthClientSecret: string | undefined; 121 let oauthRedirectUri: string | undefined; 122 const config = new ConfigManager(); 123 await config.load(); 124 125 if (config.isAuthenticated()) { 126 try { 127 // If we generated the name, use it for the domain too 128 // Otherwise, prompt for domain 129 if (wasNameGenerated) { 130 sliceDomain = `network.slices.${projectName}`; 131 } else { 132 // User provided a name, ask for domain 133 console.log(`\n${cyan("🌐 Setting up your slice domain:")}`); 134 console.log( 135 ` ${dim("Format: com.example (reverse domain notation)")}` 136 ); 137 console.log(` ${dim("Leave blank to generate one automatically")}`); 138 139 const userDomain = prompt( 140 "\nEnter your domain (or press Enter to generate):" 141 ); 142 143 if (userDomain && userDomain.trim()) { 144 // Validate domain format (basic check for at least one dot) 145 if (!userDomain.includes(".")) { 146 logger.error( 147 "Domain must contain at least one dot (e.g., com.example)" 148 ); 149 Deno.exit(1); 150 } 151 sliceDomain = userDomain.trim(); 152 } else { 153 // Generate a domain 154 sliceDomain = generateDomain(); 155 console.log(` Generated domain: ${bold(sliceDomain)}`); 156 } 157 } 158 159 console.log(`\n${cyan("🌐 Creating a slice on the Slices network:")}`); 160 console.log(` Slice name: ${bold(projectName)}`); 161 console.log(` Domain: ${bold(sliceDomain)}`); 162 console.log( 163 ` ${dim("This will be your unique namespace for collections")}` 164 ); 165 166 const createSlice = confirm("\nCreate this slice?"); 167 if (!createSlice) { 168 logger.info( 169 "Skipping slice creation. You can create one later at https://slices.network" 170 ); 171 } else { 172 logger.info("Creating slice..."); 173 const client = await createAuthenticatedClient( 174 SYSTEM_SLICE_URI, 175 DEFAULT_API_URL 176 ); 177 178 // Check if domain already exists in the system slice 179 const existingSlices = await client.network.slices.slice.getRecords({ 180 where: { domain: { eq: sliceDomain } }, 181 limit: 1, 182 }); 183 184 if (existingSlices.records.length > 0) { 185 logger.error(`A slice with domain '${sliceDomain}' already exists`); 186 logger.info("Please try again with a different name or domain"); 187 Deno.exit(1); 188 } 189 190 const result = await client.network.slices.slice.createRecord({ 191 name: projectName, 192 domain: sliceDomain, 193 createdAt: new Date().toISOString(), 194 }); 195 196 sliceUri = result.uri; 197 logger.success(`Created slice: ${sliceUri}`); 198 199 // Ask if they want to create OAuth client 200 const createOAuth = confirm( 201 "\nWould you like to create OAuth credentials for this slice?" 202 ); 203 if (createOAuth) { 204 logger.info("Creating OAuth client..."); 205 206 // Default redirect URI for local development 207 const redirectUri = "http://localhost:8080/oauth/callback"; 208 209 try { 210 const oauthResult = 211 await client.network.slices.slice.createOAuthClient({ 212 sliceUri: sliceUri, 213 clientName: `${projectName} Development`, 214 redirectUris: [redirectUri], 215 grantTypes: ["authorization_code", "refresh_token"], 216 responseTypes: ["code"], 217 scope: "profile openid atproto transition:generic", 218 }); 219 220 // Store OAuth credentials to add to .env later 221 oauthClientId = oauthResult.clientId; 222 oauthClientSecret = oauthResult.clientSecret; 223 oauthRedirectUri = redirectUri; 224 225 logger.success("Created OAuth client!"); 226 console.log(` Client ID: ${cyan(oauthResult.clientId)}`); 227 console.log( 228 ` ${dim("Client secret will be added to your .env file")}` 229 ); 230 } catch (oauthError) { 231 const err = oauthError as Error; 232 logger.warn(`Could not create OAuth client: ${err.message}`); 233 logger.info( 234 "You can create OAuth credentials later at https://slices.network" 235 ); 236 } 237 } 238 } 239 } catch (error) { 240 const err = error as Error; 241 logger.warn(`Could not create slice: ${err.message}`); 242 logger.info("You can create a slice later at https://slices.network"); 243 } 244 } else { 245 logger.info("Not authenticated - skipping slice creation"); 246 logger.info( 247 "Run 'slices login' to authenticate, then create a slice at https://slices.network" 248 ); 249 } 250 251 try { 252 // Create target directory 253 await ensureDir(targetDir); 254 255 // Extract embedded templates 256 await extractEmbeddedTemplates(targetDir, projectName, templateName); 257 258 // Update .env.example with slice URI if we created one 259 if (sliceUri) { 260 const envExamplePath = join(targetDir, ".env.example"); 261 try { 262 let envContent = await Deno.readTextFile(envExamplePath); 263 envContent = envContent.replace( 264 /SLICE_URI=.*/, 265 `SLICE_URI="${sliceUri}"` 266 ); 267 await Deno.writeTextFile(envExamplePath, envContent); 268 } catch { 269 // Ignore if can't update .env.example 270 } 271 } 272 273 // Create .env file with OAuth credentials if we have them 274 if (oauthClientId && oauthClientSecret) { 275 const envPath = join(targetDir, ".env"); 276 const envExamplePath = join(targetDir, ".env.example"); 277 278 try { 279 // Read the example file as a template 280 let envContent = await Deno.readTextFile(envExamplePath); 281 282 // Replace with actual values 283 if (sliceUri) { 284 envContent = envContent.replace( 285 /SLICE_URI=.*/, 286 `SLICE_URI="${sliceUri}"` 287 ); 288 } 289 envContent = envContent.replace( 290 /OAUTH_CLIENT_ID=.*/, 291 `OAUTH_CLIENT_ID="${oauthClientId}"` 292 ); 293 envContent = envContent.replace( 294 /OAUTH_CLIENT_SECRET=.*/, 295 `OAUTH_CLIENT_SECRET="${oauthClientSecret}"` 296 ); 297 envContent = envContent.replace( 298 /OAUTH_REDIRECT_URI=.*/, 299 `OAUTH_REDIRECT_URI="${oauthRedirectUri}"` 300 ); 301 envContent = envContent.replace( 302 /OAUTH_AIP_BASE_URL=.*/, 303 `OAUTH_AIP_BASE_URL="${DEFAULT_AIP_BASE_URL}"` 304 ); 305 envContent = envContent.replace( 306 /API_URL=.*/, 307 `API_URL="${DEFAULT_API_URL}"` 308 ); 309 310 // Write the actual .env file 311 await Deno.writeTextFile(envPath, envContent); 312 logger.success("Created .env file with your OAuth credentials"); 313 } catch (error) { 314 const err = error as Error; 315 logger.warn(`Could not create .env file: ${err.message}`); 316 } 317 } 318 319 // Create slices.json file with actual slice URI 320 if (sliceUri) { 321 const slicesJsonPath = join(targetDir, "slices.json"); 322 const slicesConfig = { 323 slice: sliceUri, 324 lexiconPath: "./lexicons", 325 clientOutputPath: "./src/generated_client.ts", 326 }; 327 328 try { 329 await Deno.writeTextFile( 330 slicesJsonPath, 331 JSON.stringify(slicesConfig, null, 2) + "\n" 332 ); 333 logger.success("Created slices.json with your slice configuration"); 334 } catch (error) { 335 const err = error as Error; 336 logger.warn(`Could not create slices.json file: ${err.message}`); 337 } 338 } 339 340 // Pull lexicons from the base slice w/ Bsky Profile Lexicons 341 try { 342 logger.info("Pulling base lexicons..."); 343 344 // Change to the project directory and run pull 345 const originalCwd = Deno.cwd(); 346 Deno.chdir(targetDir); 347 348 // Pull from the base Slices platform slice which has all the proper lexicons 349 await pullCommand(["--slice", REFERENCE_SLICE_URI], {}); 350 351 logger.success("Pulled base lexicons"); 352 353 // If we created a slice, push the lexicons to it 354 if (sliceUri) { 355 logger.info("Pushing lexicons to your slice..."); 356 await pushCommand([], {}); 357 logger.success("Pushed lexicons to your slice"); 358 } 359 360 // Generate TypeScript client from lexicons 361 logger.info("Generating TypeScript client..."); 362 await codegenCommand([], {}); 363 logger.success("Generated TypeScript client"); 364 365 // Change back to original directory 366 Deno.chdir(originalCwd); 367 } catch (error) { 368 const err = error as Error; 369 logger.warn(`Could not pull lexicons: ${err.message}`); 370 371 // Create empty lexicons folder as fallback 372 const lexiconsPath = join(targetDir, "lexicons"); 373 try { 374 await ensureDir(lexiconsPath); 375 await Deno.writeTextFile(join(lexiconsPath, ".gitkeep"), ""); 376 } catch { 377 // Ignore if we can't create the folder 378 } 379 } 380 381 // Initialize git repository 382 try { 383 const gitInit = new Deno.Command("git", { 384 args: ["init"], 385 cwd: targetDir, 386 stdout: "piped", 387 stderr: "piped", 388 }); 389 390 const result = await gitInit.output(); 391 if (result.success) { 392 logger.success("Initialized git repository"); 393 } else { 394 const stderr = new TextDecoder().decode(result.stderr); 395 logger.warn(`Could not initialize git repository: ${stderr}`); 396 } 397 } catch (error) { 398 const err = error as Error; 399 logger.warn(`Could not initialize git repository: ${err.message}`); 400 } 401 402 logger.success(`Created project: ${projectName}`); 403 404 console.log(`\n${green("✨ Your project is ready!")}\n`); 405 console.log(`${bold("Next steps:")}`); 406 console.log(` 1. cd ${cyan(projectName)}`); 407 if (oauthClientId && oauthClientSecret) { 408 console.log(` 2. deno task dev`); 409 console.log(` ${dim(" Your .env file is already configured!")}`); 410 } else { 411 console.log(` 2. cp .env.example .env`); 412 console.log(` 3. ${dim("# Add your OAuth credentials to .env")}`); 413 console.log(` 4. deno task dev`); 414 } 415 416 if (sliceUri && sliceDomain) { 417 console.log(`\n${bold("Your slice details:")}`); 418 console.log(` Name: ${cyan(projectName)}`); 419 console.log(` Domain: ${cyan(sliceDomain)}`); 420 console.log(` URI: ${dim(sliceUri)}`); 421 } 422 423 console.log(`\n${bold("Available commands:")}`); 424 console.log(` ${cyan("deno task dev")} Start development server`); 425 console.log(` ${cyan("deno task start")} Start production server`); 426 console.log(` ${cyan("deno fmt")} Format code`); 427 } catch (error) { 428 const err = error as Error; 429 logger.error("Failed to create project:", err.message); 430 Deno.exit(1); 431 } 432} 433 434async function extractEmbeddedTemplates( 435 targetDir: string, 436 projectName: string, 437 templateName: string 438) { 439 try { 440 const templates = getTemplatesForName(templateName); 441 442 if (templates.size === 0) { 443 throw new Error(`No templates found for: ${templateName}`); 444 } 445 446 for (const [relativePath, content] of templates.entries()) { 447 const fullPath = join(targetDir, relativePath); 448 const dir = dirname(fullPath); 449 450 // Ensure directory exists 451 await ensureDir(dir); 452 453 // Write file 454 await Deno.writeFile(fullPath, content); 455 } 456 457 // Process template files for variable replacement 458 await processTemplateFiles(targetDir, projectName); 459 460 logger.info(`Template files extracted and processed (${templateName})`); 461 } catch (error) { 462 const err = error as Error; 463 logger.error("Failed to extract templates:", err.message); 464 throw error; 465 } 466} 467 468async function processTemplateFiles(targetDir: string, projectName: string) { 469 // Process files that need variable replacement 470 const filesToProcess = ["README.md", "src/config.ts"]; 471 472 for (const file of filesToProcess) { 473 const filePath = join(targetDir, file); 474 try { 475 const content = await Deno.readTextFile(filePath); 476 const processed = content.replace(/{{PROJECT_NAME}}/g, projectName); 477 await Deno.writeTextFile(filePath, processed); 478 } catch (error) { 479 const err = error as Error; 480 logger.warn(`Could not process template file ${file}:`, err.message); 481 } 482 } 483} 484 485function showInitHelp() { 486 console.log(` 487Initialize a new Deno project with OAuth authentication 488 489USAGE: 490 slices init <project-name> Create project with specified name 491 slices init Create project with random generated name 492 slices init --name <name> Create project with specified name 493 slices init --template <template-name> Specify template to use 494 495ARGUMENTS: 496 <project-name> Name of the project to create (optional) 497 498OPTIONS: 499 -n, --name <name> Project name 500 -t, --template <name> Template to use (default: deno-ssr) 501 Available: ${AVAILABLE_TEMPLATES.join(", ")} 502 -h, --help Show this help message 503 504EXAMPLES: 505 slices init my-app Creates "my-app" project with deno-ssr template 506 slices init Creates project with random name 507 508FEATURES: 509 • Automatically creates a matching slice (if authenticated) 510 • Generates domain in format: network.slices.<project-name> 511 • Updates .env.example with slice URI 512 • Deno SSR with Preact and JSX 513 • OAuth authentication with PKCE flow 514 • Session management with SQLite 515 • HTMX for interactive components 516 • Tailwind CSS for styling 517 • Feature-based architecture 518`); 519}