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.
- package/README.md +131 -0
- package/bin/cli.js +6 -0
- package/package.json +31 -0
- package/src/cli-detector.js +104 -0
- package/src/config.js +25 -0
- package/src/design-flow.js +442 -0
- package/src/index.js +126 -0
- package/src/launcher.js +42 -0
- package/src/license-validator.js +85 -0
- package/src/messages.js +119 -0
- package/src/orchestrators/existing-designs.ts +176 -0
- package/src/page-mapper.js +247 -0
- package/src/prompt-generator.js +429 -0
- package/src/scenarios.js +217 -0
- package/src/setup-wizard.js +315 -0
- package/src/validators.js +153 -0
|
@@ -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
|
+
};
|