create-ripple 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,136 @@
1
+ import prompts from 'prompts';
2
+ import { validateProjectName } from './validation.js';
3
+ import { getTemplateChoices } from './templates.js';
4
+ import { red } from 'kleur/colors';
5
+
6
+ /**
7
+ * Prompt for project name
8
+ * @param {string} defaultName - Default project name
9
+ * @returns {Promise<string>} - Project name
10
+ */
11
+ export async function promptProjectName(defaultName = 'my-ripple-app') {
12
+ const response = await prompts({
13
+ type: 'text',
14
+ name: 'projectName',
15
+ message: 'What is your project named?',
16
+ initial: defaultName,
17
+ validate: (value) => {
18
+ const validation = validateProjectName(value);
19
+ return validation.valid || validation.message;
20
+ }
21
+ });
22
+
23
+ if (!response.projectName) {
24
+ console.log(red('✖ Operation cancelled'));
25
+ process.exit(1);
26
+ }
27
+
28
+ return response.projectName;
29
+ }
30
+
31
+ /**
32
+ * Prompt for template selection
33
+ * @returns {Promise<string>} - Selected template name
34
+ */
35
+ export async function promptTemplate() {
36
+ const response = await prompts({
37
+ type: 'select',
38
+ name: 'template',
39
+ message: 'Which template would you like to use?',
40
+ choices: getTemplateChoices(),
41
+ initial: 0
42
+ });
43
+
44
+ if (!response.template) {
45
+ console.log(red('✖ Operation cancelled'));
46
+ process.exit(1);
47
+ }
48
+
49
+ return response.template;
50
+ }
51
+
52
+ /**
53
+ * Prompt for directory overwrite confirmation
54
+ * @param {string} projectName - The project name
55
+ * @returns {Promise<boolean>} - Whether to overwrite
56
+ */
57
+ export async function promptOverwrite(projectName) {
58
+ const response = await prompts({
59
+ type: 'confirm',
60
+ name: 'overwrite',
61
+ message: `Directory "${projectName}" already exists. Continue anyway?`,
62
+ initial: false
63
+ });
64
+
65
+ if (response.overwrite === undefined) {
66
+ console.log(red('✖ Operation cancelled'));
67
+ process.exit(1);
68
+ }
69
+
70
+ return response.overwrite;
71
+ }
72
+
73
+ /**
74
+ * Prompt for package manager selection
75
+ * @returns {Promise<string>} - Selected package manager
76
+ */
77
+ export async function promptPackageManager() {
78
+ const response = await prompts({
79
+ type: 'select',
80
+ name: 'packageManager',
81
+ message: 'Which package manager would you like to use?',
82
+ choices: [
83
+ { title: 'npm', value: 'npm', description: 'Use npm for dependency management' },
84
+ { title: 'yarn', value: 'yarn', description: 'Use Yarn for dependency management' },
85
+ { title: 'pnpm', value: 'pnpm', description: 'Use pnpm for dependency management' }
86
+ ],
87
+ initial: 0
88
+ });
89
+
90
+ if (!response.packageManager) {
91
+ console.log(red('✖ Operation cancelled'));
92
+ process.exit(1);
93
+ }
94
+
95
+ return response.packageManager;
96
+ }
97
+
98
+ /**
99
+ * Prompt for TypeScript usage
100
+ * @returns {Promise<boolean>} - Whether to use TypeScript
101
+ */
102
+ export async function promptTypeScript() {
103
+ const response = await prompts({
104
+ type: 'confirm',
105
+ name: 'typescript',
106
+ message: 'Would you like to use TypeScript?',
107
+ initial: true
108
+ });
109
+
110
+ if (response.typescript === undefined) {
111
+ console.log(red('✖ Operation cancelled'));
112
+ process.exit(1);
113
+ }
114
+
115
+ return response.typescript;
116
+ }
117
+
118
+ /**
119
+ * Prompt for Git initialization
120
+ * @returns {Promise<boolean>} - Whether to initialize Git
121
+ */
122
+ export async function promptGitInit() {
123
+ const response = await prompts({
124
+ type: 'confirm',
125
+ name: 'gitInit',
126
+ message: 'Initialize a new Git repository?',
127
+ initial: true
128
+ });
129
+
130
+ if (response.gitInit === undefined) {
131
+ console.log(red('✖ Operation cancelled'));
132
+ process.exit(1);
133
+ }
134
+
135
+ return response.gitInit;
136
+ }
@@ -0,0 +1,97 @@
1
+ import { join } from 'node:path';
2
+ import { existsSync, mkdirSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import degit from 'degit';
5
+ import { GITHUB_REPO, GITHUB_TEMPLATES_DIRECTORY, TEMPLATES } from '../constants.js';
6
+
7
+ /**
8
+ * Get template by name
9
+ * @param {string} templateName - The template name
10
+ * @returns {object|null} - Template object or null if not found
11
+ */
12
+ export function getTemplate(templateName) {
13
+ return TEMPLATES.find((template) => template.name === templateName) || null;
14
+ }
15
+
16
+ /**
17
+ * Get all available template names
18
+ * @returns {string[]} - Array of template names
19
+ */
20
+ export function getTemplateNames() {
21
+ return TEMPLATES.map((template) => template.name);
22
+ }
23
+
24
+ /**
25
+ * Get template choices for prompts
26
+ * @returns {object[]} - Array of choice objects for prompts
27
+ */
28
+ export function getTemplateChoices() {
29
+ return TEMPLATES.map((template) => ({
30
+ title: template.display,
31
+ description: template.description,
32
+ value: template.name
33
+ }));
34
+ }
35
+
36
+ /**
37
+ * Validate if template exists in our template list
38
+ * @param {string} templateName - The template name to validate
39
+ * @returns {boolean} - True if template exists in TEMPLATES list
40
+ */
41
+ export function validateTemplate(templateName) {
42
+ if (!templateName) return false;
43
+ const template = getTemplate(templateName);
44
+ return template !== null;
45
+ }
46
+
47
+ /**
48
+ * Download template from GitHub repository
49
+ * @param {string} templateName - The template name to download
50
+ * @returns {Promise<string>} - Path to downloaded template directory
51
+ */
52
+ export async function downloadTemplate(templateName) {
53
+ if (!validateTemplate(templateName)) {
54
+ throw new Error(`Template "${templateName}" not found`);
55
+ }
56
+
57
+ // Create a temporary directory for the template
58
+ const tempDir = join(tmpdir(), `ripple-template-${templateName}-${Date.now()}`);
59
+ mkdirSync(tempDir, { recursive: true });
60
+
61
+ // Use degit to download the specific template from GitHub
62
+ const repoUrl = `${GITHUB_REPO}/${GITHUB_TEMPLATES_DIRECTORY}/${templateName}`;
63
+ const emitter = degit(repoUrl, {
64
+ cache: false,
65
+ force: true,
66
+ verbose: false
67
+ });
68
+
69
+ try {
70
+ await emitter.clone(tempDir);
71
+ return tempDir;
72
+ } catch (error) {
73
+ throw new Error(`Failed to download template "${templateName}": ${error.message}`);
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Get template directory path (for local development)
79
+ * @param {string} templateName - The template name
80
+ * @returns {string} - Absolute path to template directory
81
+ */
82
+ export function getLocalTemplatePath(templateName) {
83
+ // This is used for local development in the monorepo
84
+ const repoRoot = join(process.cwd(), '../../../');
85
+ return join(repoRoot, 'templates', templateName);
86
+ }
87
+
88
+ /**
89
+ * Check if we're running in development mode (monorepo)
90
+ * @returns {boolean} - True if in development mode
91
+ */
92
+ export function isLocalDevelopment() {
93
+ // Check if we're in the monorepo by looking for the templates directory
94
+ const repoRoot = join(process.cwd(), '../../../');
95
+ const templatesDir = join(repoRoot, 'templates');
96
+ return existsSync(templatesDir);
97
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Validation utilities for project creation
3
+ */
4
+
5
+ /**
6
+ * Validates a project name according to npm package naming rules
7
+ * @param {string} name - The project name to validate
8
+ * @returns {object} - Object with valid boolean and message string
9
+ */
10
+ export function validateProjectName(name) {
11
+ if (typeof name !== 'string' || name === null || name === undefined) {
12
+ return {
13
+ valid: false,
14
+ message: 'Project name is required'
15
+ };
16
+ }
17
+
18
+ name = name.trim();
19
+
20
+ if (name.length === 0) {
21
+ return {
22
+ valid: false,
23
+ message: 'Project name cannot be empty'
24
+ };
25
+ }
26
+
27
+ // Check length (npm package names have a 214 character limit)
28
+ if (name.length > 214) {
29
+ return {
30
+ valid: false,
31
+ message: 'Project name must be less than 214 characters'
32
+ };
33
+ }
34
+
35
+ // Check for valid characters (npm allows lowercase letters, numbers, hyphens, and dots)
36
+ if (!/^[a-z0-9._-]+$/.test(name)) {
37
+ return {
38
+ valid: false,
39
+ message:
40
+ 'Project name can only contain lowercase letters, numbers, hyphens, dots, and underscores'
41
+ };
42
+ }
43
+
44
+ // Cannot start with dot or underscore
45
+ if (name.startsWith('.') || name.startsWith('_')) {
46
+ return {
47
+ valid: false,
48
+ message: 'Project name cannot start with a dot or underscore'
49
+ };
50
+ }
51
+
52
+ // Cannot end with dot
53
+ if (name.endsWith('.')) {
54
+ return {
55
+ valid: false,
56
+ message: 'Project name cannot end with a dot'
57
+ };
58
+ }
59
+
60
+ // Cannot contain consecutive dots
61
+ if (name.includes('..')) {
62
+ return {
63
+ valid: false,
64
+ message: 'Project name cannot contain consecutive dots'
65
+ };
66
+ }
67
+
68
+ // Reserved names
69
+ const reservedNames = [
70
+ 'node_modules',
71
+ 'favicon.ico',
72
+ 'con',
73
+ 'prn',
74
+ 'aux',
75
+ 'nul',
76
+ 'com1',
77
+ 'com2',
78
+ 'com3',
79
+ 'com4',
80
+ 'com5',
81
+ 'com6',
82
+ 'com7',
83
+ 'com8',
84
+ 'com9',
85
+ 'lpt1',
86
+ 'lpt2',
87
+ 'lpt3',
88
+ 'lpt4',
89
+ 'lpt5',
90
+ 'lpt6',
91
+ 'lpt7',
92
+ 'lpt8',
93
+ 'lpt9'
94
+ ];
95
+
96
+ if (reservedNames.includes(name.toLowerCase())) {
97
+ return {
98
+ valid: false,
99
+ message: `"${name}" is a reserved name and cannot be used`
100
+ };
101
+ }
102
+
103
+ return {
104
+ valid: true,
105
+ message: ''
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Converts a project name to a valid directory name
111
+ * @param {string} name - The project name
112
+ * @returns {string} - A valid directory name
113
+ */
114
+ export function sanitizeDirectoryName(name) {
115
+ return name
116
+ .toLowerCase()
117
+ .replace(/[^a-z0-9-]/g, '-')
118
+ .replace(/^-+|-+$/g, '')
119
+ .replace(/-+/g, '-');
120
+ }
121
+
122
+ /**
123
+ * Validates directory path and checks if it's writable
124
+ * @param {string} path - The directory path to validate
125
+ * @returns {object} - Object with valid boolean and message string
126
+ */
127
+ export function validateDirectoryPath(path) {
128
+ if (!path || typeof path !== 'string') {
129
+ return {
130
+ valid: false,
131
+ message: 'Directory path is required'
132
+ };
133
+ }
134
+
135
+ // Check if path is absolute or relative
136
+ if (path.startsWith('/') && path.length < 2) {
137
+ return {
138
+ valid: false,
139
+ message: 'Cannot create project in root directory'
140
+ };
141
+ }
142
+
143
+ // Check for invalid characters in path
144
+ if (/[<>:"|?*]/.test(path)) {
145
+ return {
146
+ valid: false,
147
+ message: 'Directory path contains invalid characters'
148
+ };
149
+ }
150
+
151
+ return {
152
+ valid: true,
153
+ message: ''
154
+ };
155
+ }
@@ -0,0 +1,179 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { dirname } from 'node:path';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+
12
+ const CLI_PATH = join(__dirname, '../../src/index.js');
13
+
14
+ describe('CLI Integration Tests', () => {
15
+ let testDir;
16
+
17
+ beforeEach(() => {
18
+ testDir = join(tmpdir(), `cli-test-${Date.now()}`);
19
+ mkdirSync(testDir, { recursive: true });
20
+ });
21
+
22
+ afterEach(() => {
23
+ if (existsSync(testDir)) {
24
+ rmSync(testDir, { recursive: true, force: true });
25
+ }
26
+ });
27
+
28
+ // Helper function to run CLI commands
29
+ const runCLI = (args = [], input = '', timeout = 10000) => {
30
+ return new Promise((resolve, reject) => {
31
+ const child = spawn('node', [CLI_PATH, ...args], {
32
+ cwd: testDir,
33
+ stdio: 'pipe'
34
+ });
35
+
36
+ let stdout = '';
37
+ let stderr = '';
38
+
39
+ child.stdout.on('data', (data) => {
40
+ stdout += data.toString();
41
+ });
42
+
43
+ child.stderr.on('data', (data) => {
44
+ stderr += data.toString();
45
+ });
46
+
47
+ child.on('close', (code) => {
48
+ resolve({ code, stdout, stderr });
49
+ });
50
+
51
+ child.on('error', reject);
52
+
53
+ if (input) {
54
+ child.stdin.write(input);
55
+ }
56
+ child.stdin.end();
57
+
58
+ setTimeout(() => {
59
+ child.kill();
60
+ reject(new Error('Command timed out'));
61
+ }, timeout);
62
+ });
63
+ };
64
+
65
+ it('should show help when --help flag is used', async () => {
66
+ const result = await runCLI(['--help']);
67
+
68
+ expect(result.code).toBe(0);
69
+ expect(result.stdout).toContain('Interactive CLI tool for creating Ripple applications');
70
+ expect(result.stdout).toContain('Usage: create-ripple-app');
71
+ expect(result.stdout).toContain('Arguments:');
72
+ expect(result.stdout).toContain('Options:');
73
+ expect(result.stdout).toContain('--template');
74
+ expect(result.stdout).toContain('--package-manager');
75
+ });
76
+
77
+ it('should show version when --version flag is used', async () => {
78
+ const result = await runCLI(['--version']);
79
+
80
+ expect(result.code).toBe(0);
81
+ expect(result.stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
82
+ });
83
+
84
+ it('should create project with all arguments provided', async () => {
85
+ const projectName = 'test-cli-project';
86
+ const result = await runCLI([
87
+ projectName,
88
+ '--template', 'basic',
89
+ '--package-manager', 'npm',
90
+ '--no-git',
91
+ '--yes'
92
+ ]);
93
+
94
+ expect(result.code).toBe(0);
95
+ expect(result.stdout).toContain('Welcome to Create Ripple App');
96
+ expect(result.stdout).toContain('Creating Ripple app');
97
+ expect(result.stdout).toContain('Project created successfully');
98
+ expect(result.stdout).toContain('Next steps:');
99
+ expect(result.stdout).toContain(`cd ${projectName}`);
100
+ expect(result.stdout).toContain('npm install');
101
+ expect(result.stdout).toContain('npm run dev');
102
+
103
+ expect(existsSync(join(testDir, projectName))).toBe(true);
104
+ expect(existsSync(join(testDir, projectName, 'package.json'))).toBe(true);
105
+ });
106
+
107
+ it('should handle invalid template gracefully', async () => {
108
+ const result = await runCLI([
109
+ 'test-project',
110
+ '--template', 'invalid-template',
111
+ '--yes'
112
+ ]);
113
+
114
+ expect(result.code).toBe(1);
115
+ expect(result.stderr).toContain('Template "invalid-template" not found');
116
+ expect(result.stderr).toContain('Available templates:');
117
+ });
118
+
119
+ it('should handle invalid project name gracefully', async () => {
120
+ const result = await runCLI([
121
+ 'Invalid Project Name!',
122
+ '--yes'
123
+ ]);
124
+
125
+ expect(result.code).toBe(1);
126
+ expect(result.stderr).toContain('Project name can only contain lowercase letters');
127
+ });
128
+
129
+ it('should show different package manager commands based on selection', async () => {
130
+ const projectName = 'test-pnpm-project';
131
+ const result = await runCLI([
132
+ projectName,
133
+ '--template', 'basic',
134
+ '--package-manager', 'pnpm',
135
+ '--yes'
136
+ ]);
137
+
138
+ expect(result.code).toBe(0);
139
+ expect(result.stdout).toContain('pnpm install');
140
+ expect(result.stdout).toContain('pnpm dev');
141
+ });
142
+
143
+ it('should handle yarn package manager', async () => {
144
+ const projectName = 'test-yarn-project';
145
+ const result = await runCLI([
146
+ projectName,
147
+ '--template', 'basic',
148
+ '--package-manager', 'yarn',
149
+ '--yes'
150
+ ]);
151
+
152
+ expect(result.code).toBe(0);
153
+ expect(result.stdout).toContain('yarn install');
154
+ expect(result.stdout).toContain('yarn dev');
155
+ });
156
+
157
+ it('should handle project directory that already exists (with --yes)', async () => {
158
+ const projectName = 'existing-project';
159
+ const projectPath = join(testDir, projectName);
160
+
161
+ mkdirSync(projectPath, { recursive: true });
162
+ writeFileSync(join(projectPath, 'existing-file.txt'), 'test');
163
+
164
+ const result = await runCLI([
165
+ projectName,
166
+ '--template', 'basic',
167
+ '--yes'
168
+ ]);
169
+
170
+ expect(result.code).toBe(0);
171
+ expect(result.stdout).toContain('Project created successfully');
172
+ });
173
+
174
+ it('should validate all required dependencies are available', async () => {
175
+ const result = await runCLI(['--help']);
176
+ expect(result.code).toBe(0);
177
+ expect(result.stderr).toBe('');
178
+ });
179
+ });