create-propelkit 1.0.0

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,315 @@
1
+ const inquirer = require('inquirer');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const chalk = require('chalk');
5
+
6
+ const SETUP_MARKER = '.propelkit-setup-complete';
7
+
8
+ /**
9
+ * Validation functions for edge case handling
10
+ */
11
+
12
+ /**
13
+ * Validate brand name
14
+ * - Rejects empty strings and whitespace-only
15
+ * - Allows Unicode letters, numbers, punctuation, spaces, emoji
16
+ * - Rejects null bytes and control characters
17
+ * - Max length: 100 characters
18
+ */
19
+ function validateBrandName(input) {
20
+ const trimmed = input.trim();
21
+
22
+ if (!trimmed) {
23
+ return { valid: false, error: 'Brand name cannot be empty' };
24
+ }
25
+
26
+ // Reject null bytes and control characters (except whitespace)
27
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(trimmed)) {
28
+ return { valid: false, error: 'Brand name contains invalid control characters' };
29
+ }
30
+
31
+ if (trimmed.length > 100) {
32
+ return { valid: false, error: 'Brand name must be 100 characters or less' };
33
+ }
34
+
35
+ // Accept Unicode letters, numbers, punctuation, spaces, and symbols (including emoji)
36
+ // \p{L} = letters, \p{N} = numbers, \p{P} = punctuation, \p{Z} = spaces, \p{S} = symbols (emoji)
37
+ const validPattern = /^[\p{L}\p{N}\p{P}\p{Z}\p{S}\s]+$/u;
38
+ if (!validPattern.test(trimmed)) {
39
+ return { valid: false, error: 'Brand name contains invalid characters' };
40
+ }
41
+
42
+ return { valid: true, value: trimmed };
43
+ }
44
+
45
+ /**
46
+ * Validate tagline
47
+ * - Same rules as brand name
48
+ * - Max length: 200 characters
49
+ */
50
+ function validateTagline(input) {
51
+ const trimmed = input.trim();
52
+
53
+ if (!trimmed) {
54
+ return { valid: false, error: 'Tagline cannot be empty' };
55
+ }
56
+
57
+ if (/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(trimmed)) {
58
+ return { valid: false, error: 'Tagline contains invalid control characters' };
59
+ }
60
+
61
+ if (trimmed.length > 200) {
62
+ return { valid: false, error: 'Tagline must be 200 characters or less' };
63
+ }
64
+
65
+ const validPattern = /^[\p{L}\p{N}\p{P}\p{Z}\p{S}\s]+$/u;
66
+ if (!validPattern.test(trimmed)) {
67
+ return { valid: false, error: 'Tagline contains invalid characters' };
68
+ }
69
+
70
+ return { valid: true, value: trimmed };
71
+ }
72
+
73
+ /**
74
+ * Validate email
75
+ * - Max length: 254 characters (RFC 5321)
76
+ * - Lowercase transformation
77
+ */
78
+ function validateEmail(input) {
79
+ const trimmed = input.trim().toLowerCase();
80
+
81
+ if (!trimmed) {
82
+ return { valid: false, error: 'Email cannot be empty' };
83
+ }
84
+
85
+ if (!/\S+@\S+\.\S+/.test(trimmed)) {
86
+ return { valid: false, error: 'Enter a valid email address' };
87
+ }
88
+
89
+ if (trimmed.length > 254) {
90
+ return { valid: false, error: 'Email must be 254 characters or less' };
91
+ }
92
+
93
+ return { valid: true, value: trimmed };
94
+ }
95
+
96
+ /**
97
+ * Validate hex color
98
+ * - Strict #RRGGBB format (6 hex digits)
99
+ * - No leading/trailing whitespace allowed
100
+ */
101
+ function validateHexColor(input) {
102
+ // Don't trim - we want to reject inputs with leading/trailing spaces
103
+ if (input !== input.trim()) {
104
+ return { valid: false, error: 'Color must not have leading or trailing spaces' };
105
+ }
106
+
107
+ if (!/^#[0-9A-Fa-f]{6}$/.test(input)) {
108
+ return { valid: false, error: 'Color must be in #RRGGBB format (e.g., #0ea5e9)' };
109
+ }
110
+
111
+ return { valid: true, value: input.toLowerCase() };
112
+ }
113
+
114
+ /**
115
+ * Run setup wizard for Starter tier
116
+ * @param {string} projectDir - Path to cloned project
117
+ */
118
+ async function runSetupWizard(projectDir) {
119
+ // Check if already run (prevent accidental re-run)
120
+ const markerPath = path.join(projectDir, SETUP_MARKER);
121
+ if (fs.existsSync(markerPath)) {
122
+ console.log(chalk.dim('Setup already completed. Delete .propelkit-setup-complete to re-run.'));
123
+ return;
124
+ }
125
+
126
+ console.log('');
127
+ console.log(chalk.cyan.bold('Setup Wizard'));
128
+ console.log(chalk.dim('Configure your PropelKit Starter project'));
129
+ console.log('');
130
+
131
+ // === REQUIRED QUESTIONS (not skippable) ===
132
+ const brandAnswers = await inquirer.prompt([
133
+ {
134
+ type: 'input',
135
+ name: 'name',
136
+ message: 'Brand name:',
137
+ validate: input => {
138
+ const result = validateBrandName(input);
139
+ return result.valid ? true : result.error;
140
+ },
141
+ filter: input => {
142
+ const result = validateBrandName(input);
143
+ return result.valid ? result.value : input;
144
+ }
145
+ },
146
+ {
147
+ type: 'input',
148
+ name: 'tagline',
149
+ message: 'Tagline:',
150
+ validate: input => {
151
+ const result = validateTagline(input);
152
+ return result.valid ? true : result.error;
153
+ },
154
+ filter: input => {
155
+ const result = validateTagline(input);
156
+ return result.valid ? result.value : input;
157
+ }
158
+ },
159
+ {
160
+ type: 'input',
161
+ name: 'supportEmail',
162
+ message: 'Support email:',
163
+ validate: input => {
164
+ const result = validateEmail(input);
165
+ return result.valid ? true : result.error;
166
+ },
167
+ filter: input => {
168
+ const result = validateEmail(input);
169
+ return result.valid ? result.value : input;
170
+ }
171
+ }
172
+ ]);
173
+
174
+ // === OPTIONAL QUESTIONS (skippable with defaults) ===
175
+ console.log('');
176
+ console.log(chalk.dim('Optional settings (press Enter to use defaults):'));
177
+
178
+ const optionalAnswers = await inquirer.prompt([
179
+ {
180
+ type: 'input',
181
+ name: 'primaryColor',
182
+ message: 'Primary color (hex):',
183
+ default: '#0ea5e9',
184
+ validate: input => {
185
+ const result = validateHexColor(input);
186
+ return result.valid ? true : result.error;
187
+ },
188
+ filter: input => {
189
+ const result = validateHexColor(input);
190
+ return result.valid ? result.value : input;
191
+ }
192
+ },
193
+ {
194
+ type: 'confirm',
195
+ name: 'darkModeDefault',
196
+ message: 'Default to dark mode?',
197
+ default: true
198
+ }
199
+ ]);
200
+
201
+ // === UPDATE BRAND CONFIG ===
202
+ updateBrandConfig(projectDir, brandAnswers, optionalAnswers);
203
+
204
+ // === GENERATE .env.local ===
205
+ generateEnvFile(projectDir);
206
+
207
+ // === WRITE MARKER FILE ===
208
+ fs.writeFileSync(markerPath, new Date().toISOString());
209
+
210
+ // === SUCCESS MESSAGE ===
211
+ console.log('');
212
+ console.log(chalk.green.bold('Setup complete!'));
213
+ console.log('');
214
+ console.log('Files created:');
215
+ console.log(chalk.dim(' - src/config/brand.ts (updated)'));
216
+ console.log(chalk.dim(' - .env.local (with placeholders)'));
217
+ console.log('');
218
+ console.log('Next steps:');
219
+ console.log(chalk.cyan(' 1. ') + 'Fill in .env.local with your API keys');
220
+ console.log(chalk.cyan(' 2. ') + 'npm install');
221
+ console.log(chalk.cyan(' 3. ') + 'npm run dev');
222
+ console.log('');
223
+ }
224
+
225
+ /**
226
+ * Update brand.ts with user values
227
+ */
228
+ function updateBrandConfig(projectDir, brand, theme) {
229
+ const brandPath = path.join(projectDir, 'src', 'config', 'brand.ts');
230
+
231
+ if (!fs.existsSync(brandPath)) {
232
+ console.log(chalk.yellow('Warning: brand.ts not found, skipping brand config update'));
233
+ return;
234
+ }
235
+
236
+ let content = fs.readFileSync(brandPath, 'utf-8');
237
+
238
+ // Replace brand values (handles both single and double quotes)
239
+ content = content.replace(
240
+ /name:\s*['"][^'"]*['"]/,
241
+ `name: '${brand.name.replace(/'/g, "\\'")}'`
242
+ );
243
+ content = content.replace(
244
+ /tagline:\s*['"][^'"]*['"]/,
245
+ `tagline: '${brand.tagline.replace(/'/g, "\\'")}'`
246
+ );
247
+
248
+ // Support email is in contact object
249
+ content = content.replace(
250
+ /email:\s*['"][^'"]*['"],?\s*\/\/\s*support/i,
251
+ `email: '${brand.supportEmail}', // support`
252
+ );
253
+ // Alternative pattern without comment
254
+ if (!content.includes(brand.supportEmail)) {
255
+ content = content.replace(
256
+ /(contact:\s*\{[^}]*email:\s*)['"][^'"]*['"]/,
257
+ `$1'${brand.supportEmail}'`
258
+ );
259
+ }
260
+
261
+ fs.writeFileSync(brandPath, content);
262
+ console.log(chalk.dim('Updated src/config/brand.ts'));
263
+ }
264
+
265
+ /**
266
+ * Generate .env.local with helpful comments
267
+ */
268
+ function generateEnvFile(projectDir) {
269
+ const envPath = path.join(projectDir, '.env.local');
270
+
271
+ // Don't overwrite existing .env.local
272
+ if (fs.existsSync(envPath)) {
273
+ console.log(chalk.yellow('.env.local already exists, skipping'));
274
+ return;
275
+ }
276
+
277
+ const envContent = `# ==========================================
278
+ # PropelKit Environment Configuration
279
+ # ==========================================
280
+ # Fill in these values from your service dashboards
281
+
282
+ # Database (Supabase)
283
+ # Get from: https://supabase.com/dashboard/project/_/settings/api
284
+ NEXT_PUBLIC_SUPABASE_URL=your-project-url
285
+ NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
286
+ SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
287
+
288
+ # Payments (Razorpay)
289
+ # Get from: https://dashboard.razorpay.com/app/keys
290
+ NEXT_PUBLIC_RAZORPAY_KEY_ID=your-key-id
291
+ RAZORPAY_KEY_SECRET=your-key-secret
292
+ RAZORPAY_WEBHOOK_SECRET=your-webhook-secret
293
+
294
+ # Email (Resend)
295
+ # Get from: https://resend.com/api-keys
296
+ RESEND_API_KEY=your-resend-key
297
+
298
+ # Site URL (change in production)
299
+ NEXT_PUBLIC_SITE_URL=http://localhost:3000
300
+ `;
301
+
302
+ fs.writeFileSync(envPath, envContent);
303
+ console.log(chalk.dim('Created .env.local with placeholders'));
304
+ }
305
+
306
+ module.exports = {
307
+ runSetupWizard,
308
+ // For testing
309
+ _internal: {
310
+ validateBrandName,
311
+ validateTagline,
312
+ validateEmail,
313
+ validateHexColor
314
+ }
315
+ };
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Zod validation schemas for design flow inputs
3
+ * Used by design-flow.js to validate user input
4
+ */
5
+ const { z } = require('zod');
6
+
7
+ /**
8
+ * Hex color validation schema
9
+ * Accepts: #RRGGBB format (case insensitive)
10
+ * Rejects: #RGB, #RRGGBBAA, invalid hex chars
11
+ */
12
+ const HexColorSchema = z.string()
13
+ .regex(
14
+ /^#[0-9A-Fa-f]{6}$/,
15
+ 'Must be a valid hex color in #RRGGBB format (e.g., #3B82F6)'
16
+ );
17
+
18
+ /**
19
+ * Style reference enum
20
+ * Maps to aesthetic patterns for Lovable prompt generation
21
+ */
22
+ const StyleReferenceSchema = z.enum([
23
+ 'linear',
24
+ 'notion',
25
+ 'stripe',
26
+ 'vercel',
27
+ 'airbnb'
28
+ ], {
29
+ errorMap: () => ({
30
+ message: 'Style reference must be one of: linear, notion, stripe, vercel, airbnb'
31
+ })
32
+ });
33
+
34
+ /**
35
+ * Brand voice enum
36
+ * Influences copy tone and visual intensity
37
+ */
38
+ const BrandVoiceSchema = z.enum([
39
+ 'Professional',
40
+ 'Friendly',
41
+ 'Bold',
42
+ 'Playful'
43
+ ], {
44
+ errorMap: () => ({
45
+ message: 'Brand voice must be one of: Professional, Friendly, Bold, Playful'
46
+ })
47
+ });
48
+
49
+ /**
50
+ * Complete design system schema
51
+ * Validates all design inputs together
52
+ */
53
+ const DesignSystemSchema = z.object({
54
+ primaryColor: HexColorSchema,
55
+ accentColor: HexColorSchema,
56
+ styleReference: StyleReferenceSchema,
57
+ brandVoice: BrandVoiceSchema
58
+ });
59
+
60
+ /**
61
+ * Page name validation schema
62
+ * 1-50 characters, alphanumeric + spaces + hyphens
63
+ */
64
+ const PageNameSchema = z.string()
65
+ .min(1, 'Page name cannot be empty')
66
+ .max(50, 'Page name must be 50 characters or less')
67
+ .regex(
68
+ /^[A-Za-z0-9\s-]+$/,
69
+ 'Page name can only contain letters, numbers, spaces, and hyphens'
70
+ );
71
+
72
+ /**
73
+ * Validate a design system object
74
+ * @param {object} input - Design system input to validate
75
+ * @returns {{ success: boolean, data?: object, error?: string }}
76
+ */
77
+ function validateDesignSystem(input) {
78
+ const result = DesignSystemSchema.safeParse(input);
79
+
80
+ if (result.success) {
81
+ return {
82
+ success: true,
83
+ data: result.data
84
+ };
85
+ }
86
+
87
+ // Extract first error message for user-friendly output
88
+ const firstError = result.error.issues[0];
89
+ const errorPath = firstError.path.length > 0
90
+ ? `${firstError.path.join('.')}: `
91
+ : '';
92
+
93
+ return {
94
+ success: false,
95
+ error: `${errorPath}${firstError.message}`
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Validate a page name
101
+ * @param {string} input - Page name to validate
102
+ * @returns {{ success: boolean, data?: string, error?: string }}
103
+ */
104
+ function validatePageName(input) {
105
+ const result = PageNameSchema.safeParse(input);
106
+
107
+ if (result.success) {
108
+ return {
109
+ success: true,
110
+ data: result.data
111
+ };
112
+ }
113
+
114
+ return {
115
+ success: false,
116
+ error: result.error.issues[0].message
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Validate a hex color
122
+ * @param {string} input - Hex color to validate
123
+ * @returns {{ success: boolean, data?: string, error?: string }}
124
+ */
125
+ function validateHexColor(input) {
126
+ const result = HexColorSchema.safeParse(input);
127
+
128
+ if (result.success) {
129
+ return {
130
+ success: true,
131
+ data: result.data
132
+ };
133
+ }
134
+
135
+ return {
136
+ success: false,
137
+ error: result.error.issues[0].message
138
+ };
139
+ }
140
+
141
+ module.exports = {
142
+ // Schemas
143
+ HexColorSchema,
144
+ StyleReferenceSchema,
145
+ BrandVoiceSchema,
146
+ DesignSystemSchema,
147
+ PageNameSchema,
148
+
149
+ // Validation functions
150
+ validateDesignSystem,
151
+ validatePageName,
152
+ validateHexColor
153
+ };