src/index.ts 13.8 K raw
1
#!/usr/bin/env node
2
3
import { parseArgs } from "util";
4
import { readFile, writeFile } from "node:fs/promises";
5
import { mkdir } from "node:fs/promises";
6
import { existsSync } from "node:fs";
7
import { join } from "node:path";
8
import { createInterface } from "node:readline/promises";
9
10
import { fileURLToPath } from "url";
11
import { dirname } from "path";
12
import * as colors from "./utils/colors.js";
13
import yoctoSpinner from "./utils/spinner.js";
14
15
const __filename = fileURLToPath(import.meta.url);
16
17
const __dirname = dirname(__filename);
18
const COMPONENTS_DIR = join(__dirname, "components");
19
const CONFIG_FILE = "norns.json";
20
21
interface NornsConfig {
22
	components: string;
23
	includeTypes?: boolean;
24
	framework?: "typescript" | "react" | "svelte" | "vue";
25
}
26
27
const DEFAULT_CONFIG: NornsConfig = {
28
	components: "src/components",
29
	includeTypes: true,
30
	framework: "typescript",
31
};
32
33
async function loadConfig(): Promise<NornsConfig | null> {
34
	try {
35
		if (!existsSync(CONFIG_FILE)) {
36
			return null;
37
		}
38
		const configContent = await readFile(CONFIG_FILE, "utf8");
39
		return JSON.parse(configContent);
40
	} catch (error) {
41
		console.error(colors.red(`✗ Failed to load ${CONFIG_FILE}:`), error);
42
		return null;
43
	}
44
}
45
46
async function saveConfig(config: NornsConfig): Promise<void> {
47
	const spinner = yoctoSpinner({ text: `Saving ${CONFIG_FILE}` }).start();
48
	try {
49
		await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), "utf8");
50
		spinner.success(`Saved ${CONFIG_FILE}`);
51
	} catch (error) {
52
		spinner.error(`Failed to save ${CONFIG_FILE}`);
53
		console.error(error);
54
		throw error;
55
	}
56
}
57
58
async function promptUser(
59
	question: string,
60
	defaultValue?: string,
61
): Promise<string> {
62
	const rl = createInterface({
63
		input: process.stdin,
64
		output: process.stdout,
65
	});
66
67
	try {
68
		const prompt = defaultValue
69
			? `${question} (${defaultValue}): `
70
			: `${question}: `;
71
72
		const answer = await rl.question(prompt);
73
		return answer.trim() || defaultValue || "";
74
	} finally {
75
		rl.close();
76
	}
77
}
78
79
interface MenuItem {
80
	value: string;
81
	label: string;
82
}
83
84
async function selectFromMenu(
85
	question: string,
86
	items: MenuItem[],
87
	defaultIndex = 0,
88
): Promise<string> {
89
	return new Promise((resolve) => {
90
		let selectedIndex = defaultIndex;
91
		let isFirstRender = true;
92
93
		const renderMenu = () => {
94
			// Clear previous menu (move cursor up and clear lines)
95
			if (!isFirstRender) {
96
				// Move cursor up by the number of lines we printed
97
				// +2 for the blank line before question and the question itself
98
				process.stdout.write(`\x1b[${items.length + 2}A`);
99
				// Clear from cursor to end of screen
100
				process.stdout.write("\x1b[0J");
101
			}
102
			isFirstRender = false;
103
104
			// Use process.stdout.write for precise control
105
			process.stdout.write(colors.blue(`\n${question}\n`));
106
			items.forEach((item, index) => {
107
				const prefix = index === selectedIndex ? colors.green("❯ ") : "  ";
108
				const text =
109
					index === selectedIndex ? colors.cyan(item.label) : item.label;
110
				process.stdout.write(prefix + text + "\n");
111
			});
112
		};
113
114
		const onKeyPress = (str: string, key: any) => {
115
			if (key.name === "return" || key.name === "enter") {
116
				cleanup();
117
				resolve(items[selectedIndex].value);
118
				return;
119
			}
120
121
			if (key.name === "up" || str === "k") {
122
				selectedIndex =
123
					selectedIndex > 0 ? selectedIndex - 1 : items.length - 1;
124
				renderMenu();
125
			} else if (key.name === "down" || str === "j") {
126
				selectedIndex =
127
					selectedIndex < items.length - 1 ? selectedIndex + 1 : 0;
128
				renderMenu();
129
			} else if (key.ctrl && key.name === "c") {
130
				cleanup();
131
				process.exit(0);
132
			}
133
		};
134
135
		const cleanup = () => {
136
			if (process.stdin.isTTY && process.stdin.setRawMode) {
137
				process.stdin.setRawMode(false);
138
			}
139
			process.stdin.removeListener("keypress", onKeyPress);
140
			process.stdin.pause();
141
			console.log(); // Add newline after selection
142
		};
143
144
		// Enable keypress events
145
		const readline = require("readline");
146
		readline.emitKeypressEvents(process.stdin);
147
148
		if (process.stdin.isTTY && process.stdin.setRawMode) {
149
			process.stdin.setRawMode(true);
150
		}
151
152
		process.stdin.resume(); // Ensure stdin is resumed
153
		process.stdin.on("keypress", onKeyPress);
154
		renderMenu();
155
	});
156
}
157
158
async function init() {
159
	console.log(colors.yellow("∅ Initializing norns project..."));
160
161
	// Check if components.json already exists
162
	if (existsSync(CONFIG_FILE)) {
163
		console.log(colors.blue(`▸ ${CONFIG_FILE} already exists`));
164
		const overwrite = await promptUser(
165
			"Would you like to overwrite it? (y/N)",
166
			"n",
167
		);
168
		if (overwrite.toLowerCase() !== "y" && overwrite.toLowerCase() !== "yes") {
169
			console.log(colors.red("✗ Initialization cancelled"));
170
			return;
171
		}
172
	}
173
174
	console.log(colors.blue("\n▸ Setting up your components configuration...\n"));
175
176
	// Get component directory path
177
	const componentsPath = await promptUser(
178
		"Where would you like to install your components?",
179
		DEFAULT_CONFIG.components,
180
	);
181
182
	// Get TypeScript types preference
183
	const includeTypesResponse = await promptUser(
184
		"Include TypeScript definitions? (Y/n)",
185
		"y",
186
	);
187
	const includeTypes =
188
		includeTypesResponse.toLowerCase() !== "n" &&
189
		includeTypesResponse.toLowerCase() !== "no";
190
191
	// Get framework selection
192
	let framework: "typescript" | "react" | "svelte" | "vue" = "typescript";
193
	if (includeTypes) {
194
		const frameworkChoice = await selectFromMenu(
195
			"Select your framework (use arrow keys or j/k, press Enter to select):",
196
			[
197
				{ value: "typescript", label: "TypeScript (standard)" },
198
				{ value: "react", label: "React" },
199
				{ value: "svelte", label: "Svelte" },
200
				{ value: "vue", label: "Vue" },
201
			],
202
			0,
203
		);
204
		framework = frameworkChoice as "typescript" | "react" | "svelte" | "vue";
205
	}
206
207
	// Create the configuration
208
	const config: NornsConfig = {
209
		components: componentsPath,
210
		includeTypes,
211
		framework,
212
	};
213
214
	// Create components directory if it doesn't exist
215
	if (!existsSync(componentsPath)) {
216
		const dirSpinner = yoctoSpinner({
217
			text: `Creating ${componentsPath} directory`,
218
		}).start();
219
		await mkdir(componentsPath, { recursive: true });
220
		dirSpinner.success(`Created ${componentsPath} directory`);
221
	}
222
223
	// Save the configuration
224
	await saveConfig(config);
225
226
	console.log(
227
		colors.green(
228
			"\n✓ norns project initialized! You can now add components with:",
229
		),
230
	);
231
	console.log(colors.cyan("  npx norns@latest add <component-name>"));
232
	console.log(
233
		colors.blue(`\n▸ Components will be installed to: ${componentsPath}`),
234
	);
235
}
236
237
async function addComponent(componentName: string | undefined) {
238
	if (!componentName) {
239
		console.error(colors.red("✗ Please specify a component name"));
240
		console.log(colors.cyan("Usage: npx norns@latest add <component-name>"));
241
		process.exit(1);
242
	}
243
244
	console.log(colors.blue(`▸ Adding component: ${componentName}`));
245
246
	// Load configuration
247
	let config = await loadConfig();
248
249
	// If no config exists, ask user to run init first or use defaults
250
	if (!config) {
251
		console.log(colors.blue("▸ No norns.json found."));
252
		const shouldInit = await promptUser(
253
			"Would you like to run 'norns init' first? (Y/n)",
254
			"y",
255
		);
256
257
		if (
258
			shouldInit.toLowerCase() === "y" ||
259
			shouldInit.toLowerCase() === "yes" ||
260
			shouldInit === ""
261
		) {
262
			await init();
263
			config = await loadConfig();
264
		} else {
265
			console.log(colors.blue("▸ Using default configuration..."));
266
			config = DEFAULT_CONFIG;
267
		}
268
	}
269
270
	if (!config) {
271
		console.error(colors.red("✗ Failed to initialize configuration"));
272
		process.exit(1);
273
	}
274
275
	const componentsDir = config.components;
276
277
	// Create components directory if it doesn't exist
278
	if (!existsSync(componentsDir)) {
279
		const dirSpinner = yoctoSpinner({
280
			text: `Creating ${componentsDir} directory`,
281
		}).start();
282
		await mkdir(componentsDir, { recursive: true });
283
		dirSpinner.success(`Created ${componentsDir} directory`);
284
	}
285
286
	try {
287
		const sourceComponentPath = join(COMPONENTS_DIR, `${componentName}.js`);
288
289
		if (!existsSync(sourceComponentPath)) {
290
			console.error(colors.red(`✗ Component '${componentName}' not found`));
291
			console.log(colors.blue("Available components:"));
292
			console.log(colors.cyan("  - connect-wallet"));
293
			console.log(colors.cyan("  - contract-call"));
294
			process.exit(1);
295
		}
296
297
		const installSpinner = yoctoSpinner({
298
			text: `Installing ${componentName} component`,
299
		}).start();
300
301
		const componentCode = await readFile(sourceComponentPath, "utf8");
302
		const componentPath = join(componentsDir, `${componentName}.js`);
303
304
		await writeFile(componentPath, componentCode, "utf8");
305
306
		// Copy TypeScript definitions if enabled
307
		if (config.includeTypes !== false) {
308
			const framework = config.framework || "typescript";
309
			let typesFileName: string;
310
311
			switch (framework) {
312
				case "react":
313
					typesFileName = "custom-elements-jsx.ts";
314
					break;
315
				case "svelte":
316
					typesFileName = "custom-elements-svelte.ts";
317
					break;
318
				case "vue":
319
					typesFileName = "custom-elements-vue.ts";
320
					break;
321
				case "typescript":
322
				default:
323
					typesFileName = "custom-elements.ts";
324
					break;
325
			}
326
327
			const typesSourcePath = join(COMPONENTS_DIR, `../${typesFileName}`);
328
			const typesDestPath = join(componentsDir, typesFileName);
329
330
			if (existsSync(typesSourcePath)) {
331
				const typesContent = await readFile(typesSourcePath, "utf8");
332
				await writeFile(typesDestPath, typesContent, "utf8");
333
				console.log(
334
					colors.blue(
335
						`▸ Added ${framework} TypeScript definitions to ${typesDestPath}`,
336
					),
337
				);
338
			}
339
		}
340
341
		installSpinner.success(`Added ${componentName} to ${componentPath}`);
342
		console.log(colors.blue(`▸ You can now use it in your HTML:`));
343
		console.log(
344
			colors.cyan(`   <script src="components/${componentName}.js"></script>`),
345
		);
346
		console.log(colors.cyan(`   <${componentName}></${componentName}>`));
347
	} catch (error) {
348
		console.error(colors.red(`✗ Failed to add component: ${error}`));
349
		process.exit(1);
350
	}
351
}
352
353
function showHelp() {
354
	console.log(
355
		colors.yellow(`
356
357
                                 @
358
                                @@@@
359
                                  @@@@    @@@@@@@@@
360
                                    @@@@  @@@   @@@@@@
361
                                      @@@@         @@@@
362
                                    @@  @@@@        @@@@
363
                                  @@@@    @@@@       @@@
364
                   @@@@@@@      @@@@        @@@@    @@@
365
                 @@@@@@@@@@@  @@@@            @@@  @@@@
366
               @@@@         @@@@                 @@@@
367
              @@@         @@@@  @              @@@@  @
368
              @@@       @@@@   @@@@          @@@@   @@@@          @@@
369
              @@@     @@@@       @@@@      @@@@       @@@@      @@@@
370
               @@@  @@@@           @@@@  @@@@           @@@@  @@@@
371
                @@@@                 @@@@                 @@@@
372
                  @@@@                 @@@@                 @@@@
373
              @@@@  @@@@           @@@@  @@@@           @@@@  @@@
374
            @@@@      @@@@       @@@@      @@@@       @@@@     @@@
375
           @@@          @@@@   @@@@          @@@@   @@@@       @@@
376
                          @  @@@@              @  @@@@         @@@
377
                           @@@@                 @@@@         @@@@
378
                         @@@@  @@@            @@@@  @@@@@@@@@@@
379
                         @@@    @@@@        @@@@      @@@@@@
380
                        @@@       @@@@    @@@@
381
                        @@@@        @@@@  @@
382
                         @@@@         @@@@
383
                           @@@@@   @@@  @@@@
384
                             @@@@@@@@@    @@@@
385
                                            @@@@
386
                                              @
387
`),
388
	);
389
	console.log(
390
		colors.yellow(`\n∅ norns - web components for decentralized applications`),
391
		colors.cyan("\nhttps://github.com/stevedylandev/norns\n"),
392
	);
393
	console.log(colors.bold("Usage:"));
394
	console.log(
395
		colors.cyan(
396
			"  npx norns@latest init                    Initialize a new norns project with norns.json",
397
		),
398
	);
399
	console.log(
400
		colors.cyan(
401
			"  npx norns@latest add <component-name>    Add a component to your project",
402
		),
403
	);
404
	console.log(
405
		colors.cyan(
406
			"  npx norns@latest --help                  Show this help message\n",
407
		),
408
	);
409
410
	console.log(colors.bold("Examples:"));
411
	console.log(colors.green("  npx norns@latest init"));
412
	console.log(colors.green("  npx norns@latest add connect-wallet\n"));
413
414
	console.log(colors.bold("The init command will:"));
415
	console.log(colors.blue("  - Create a norns.json configuration file"));
416
	console.log(
417
		colors.blue("  - Set up your preferred component installation directory"),
418
	);
419
	console.log(colors.blue("  - Create necessary directories\n"));
420
421
	console.log(colors.bold("Available Components:"));
422
	console.log(
423
		colors.cyan("  - connect-wallet    A Web3 wallet connection component"),
424
	);
425
	console.log(
426
		colors.cyan(
427
			"  - contract-call     A Web3 contract interaction component\n",
428
		),
429
	);
430
431
	console.log(colors.bold("Configuration:"));
432
	console.log(
433
		colors.blue(
434
			"  The norns.json file controls where components are installed.",
435
		),
436
	);
437
	console.log(
438
		colors.blue(
439
			"  You can customize the installation directory during init or edit the file directly.",
440
		),
441
	);
442
}
443
444
// Parse command line arguments using Node's parseArgs
445
// process.argv includes [node_path, script_path, ...actual_args]
446
// We need to slice from index 2 to get the actual command arguments
447
const { values, positionals } = parseArgs({
448
	args: process.argv.slice(2),
449
	options: {
450
		help: { type: "boolean", short: "h" },
451
	},
452
	strict: false,
453
	allowPositionals: true,
454
});
455
456
const command = positionals[0];
457
const componentName = positionals[1];
458
459
// Wrap in async IIFE to avoid top-level await warning
460
(async () => {
461
	if (values.help) {
462
		showHelp();
463
	} else {
464
		switch (command) {
465
			case "init":
466
				await init();
467
				break;
468
			case "add":
469
				await addComponent(componentName);
470
				break;
471
			case "help":
472
				showHelp();
473
				break;
474
			default:
475
				if (!command) {
476
					showHelp();
477
				} else {
478
					console.error(colors.red(`✗ Unknown command: ${command}`));
479
					showHelp();
480
					process.exit(1);
481
				}
482
		}
483
	}
484
})();