forked from
slices.network/slices
Highly ambitious ATProtocol AppView service and sdks
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}