promptlineapp 1.3.11

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.
Files changed (3) hide show
  1. package/README.md +138 -0
  2. package/bin/cli.js +1911 -0
  3. package/package.json +41 -0
package/bin/cli.js ADDED
@@ -0,0 +1,1911 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * create-promptline-app CLI
5
+ *
6
+ * Usage:
7
+ * npx create-promptline-app my-app
8
+ * npx create-promptline-app my-app --preset saas
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const readline = require('readline');
14
+
15
+ // ANSI colors
16
+ const colors = {
17
+ reset: '\x1b[0m',
18
+ bold: '\x1b[1m',
19
+ dim: '\x1b[2m',
20
+ red: '\x1b[31m',
21
+ green: '\x1b[32m',
22
+ yellow: '\x1b[33m',
23
+ blue: '\x1b[34m',
24
+ magenta: '\x1b[35m',
25
+ cyan: '\x1b[36m',
26
+ white: '\x1b[37m',
27
+ };
28
+
29
+ const c = colors;
30
+
31
+ // Utility functions
32
+ function success(message) {
33
+ console.log(`${c.green}✓${c.reset} ${message}`);
34
+ }
35
+
36
+ function error(message) {
37
+ console.log(`${c.red}✗${c.reset} ${message}`);
38
+ }
39
+
40
+ function printLogo() {
41
+ console.log(`
42
+ ${c.cyan}╔═══════════════════════════════════════════════════════════╗
43
+ ║ ║
44
+ ║ ${c.bold}create-promptline-app${c.reset}${c.cyan} ║
45
+ ║ Build AI-powered applications with PromptLine ║
46
+ ║ ║
47
+ ╚═══════════════════════════════════════════════════════════════╝${c.reset}
48
+ `);
49
+ }
50
+
51
+ function slugify(text) {
52
+ return text
53
+ .toLowerCase()
54
+ .trim()
55
+ .replace(/[^\w\s-]/g, '')
56
+ .replace(/[-\s]+/g, '-');
57
+ }
58
+
59
+ // ===========================================
60
+ // Input Validation & Sanitization Functions
61
+ // ===========================================
62
+
63
+ /**
64
+ * Escape special YAML characters to prevent injection
65
+ * @param {string} str - The string to escape
66
+ * @returns {string} - YAML-safe escaped string
67
+ */
68
+ function escapeYaml(str) {
69
+ if (typeof str !== 'string') return str;
70
+
71
+ // YAML bare words that would be interpreted as special types
72
+ const yamlKeywords = ['true', 'false', 'yes', 'no', 'on', 'off', 'null', '~', 'y', 'n'];
73
+ if (yamlKeywords.includes(str.toLowerCase())) {
74
+ return `"${str}"`;
75
+ }
76
+
77
+ // Numbers that might be parsed as floats/ints
78
+ if (/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(str) && str !== '') {
79
+ return `"${str}"`;
80
+ }
81
+
82
+ // If string contains special YAML characters, wrap in quotes and escape internal quotes
83
+ const needsQuoting = /[:\{\}\[\],&*#?|\-<>=!%@`\n\r\t]/.test(str) ||
84
+ str.startsWith(' ') ||
85
+ str.endsWith(' ') ||
86
+ str.includes('"') ||
87
+ str.includes("'");
88
+
89
+ if (needsQuoting) {
90
+ // Escape backslashes and double quotes, then wrap in double quotes
91
+ return '"' + str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t') + '"';
92
+ }
93
+ return str;
94
+ }
95
+
96
+ /**
97
+ * Escape characters for JavaScript template literals
98
+ * Prevents code injection when embedding user input in JS templates
99
+ * @param {string} str - The string to escape
100
+ * @returns {string} - JS template literal safe string
101
+ */
102
+ function escapeJsTemplate(str) {
103
+ if (typeof str !== 'string') return str;
104
+ return str
105
+ .replace(/\\/g, '\\\\') // Escape backslashes first
106
+ .replace(/`/g, '\\`') // Escape backticks
107
+ .replace(/\$/g, '\\$'); // Escape $ to prevent ${} interpolation
108
+ }
109
+
110
+ /**
111
+ * Validate email format (RFC 5322 simplified)
112
+ * @param {string} email - Email to validate
113
+ * @returns {{valid: boolean, error?: string}}
114
+ */
115
+ function validateEmail(email) {
116
+ if (!email || email.length === 0) {
117
+ return { valid: false, error: 'Email cannot be empty' };
118
+ }
119
+ if (email.length > 254) {
120
+ return { valid: false, error: 'Email is too long (max 254 characters)' };
121
+ }
122
+ // RFC 5322 simplified regex
123
+ const emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
124
+ if (!emailRegex.test(email)) {
125
+ return { valid: false, error: 'Invalid email format' };
126
+ }
127
+ return { valid: true };
128
+ }
129
+
130
+ /**
131
+ * Validate hex color format
132
+ * @param {string} color - Color to validate (with or without #)
133
+ * @returns {{valid: boolean, error?: string, normalized?: string}}
134
+ */
135
+ function validateHexColor(color) {
136
+ if (!color || color.length === 0) {
137
+ return { valid: false, error: 'Color cannot be empty' };
138
+ }
139
+ // Remove # if present
140
+ const hex = color.startsWith('#') ? color.slice(1) : color;
141
+ // Check for valid hex format (3 or 6 characters)
142
+ if (!/^[0-9A-Fa-f]{3}$|^[0-9A-Fa-f]{6}$/.test(hex)) {
143
+ return { valid: false, error: 'Invalid hex color format. Use #RGB or #RRGGBB (e.g., #6366f1)' };
144
+ }
145
+ // Normalize to 6-character format with #
146
+ const normalized = hex.length === 3
147
+ ? '#' + hex.split('').map(c => c + c).join('')
148
+ : '#' + hex;
149
+ return { valid: true, normalized: normalized.toLowerCase() };
150
+ }
151
+
152
+ /**
153
+ * Validate and sanitize display name / description
154
+ * @param {string} str - String to validate
155
+ * @param {string} fieldName - Field name for error messages
156
+ * @param {number} maxLength - Maximum allowed length
157
+ * @returns {{valid: boolean, error?: string, sanitized?: string}}
158
+ */
159
+ function validateString(str, fieldName, maxLength = 200) {
160
+ if (!str || str.trim().length === 0) {
161
+ return { valid: false, error: `${fieldName} cannot be empty` };
162
+ }
163
+ if (str.length > maxLength) {
164
+ return { valid: false, error: `${fieldName} is too long (max ${maxLength} characters)` };
165
+ }
166
+ // Remove control characters except newlines/tabs in descriptions
167
+ const sanitized = str.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '').trim();
168
+ if (sanitized.length === 0) {
169
+ return { valid: false, error: `${fieldName} contains only invalid characters` };
170
+ }
171
+ return { valid: true, sanitized };
172
+ }
173
+
174
+ function validatePackageName(slug) {
175
+ // Security: prevent path traversal and invalid names
176
+ if (!slug || slug.length === 0) {
177
+ return { valid: false, error: 'Package name cannot be empty' };
178
+ }
179
+
180
+ // Comprehensive path traversal prevention
181
+ // Check for various path traversal patterns
182
+ const dangerousPatterns = [
183
+ '..', // Parent directory
184
+ '/', // Unix path separator
185
+ '\\', // Windows path separator
186
+ '%2e', // URL encoded .
187
+ '%2f', // URL encoded /
188
+ '%5c', // URL encoded \
189
+ '\x00', // Null byte
190
+ '~', // Home directory shortcut
191
+ ];
192
+
193
+ const lowerSlug = slug.toLowerCase();
194
+ for (const pattern of dangerousPatterns) {
195
+ if (lowerSlug.includes(pattern.toLowerCase())) {
196
+ return { valid: false, error: 'Package name contains invalid characters' };
197
+ }
198
+ }
199
+
200
+ // Must start with alphanumeric
201
+ if (!/^[a-z0-9]/.test(slug)) {
202
+ return { valid: false, error: 'Package name must start with a letter or number' };
203
+ }
204
+
205
+ // Must end with alphanumeric
206
+ if (!/[a-z0-9]$/.test(slug)) {
207
+ return { valid: false, error: 'Package name must end with a letter or number' };
208
+ }
209
+
210
+ // Only allow alphanumeric, hyphens, and underscores
211
+ if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(slug)) {
212
+ return { valid: false, error: 'Package name can only contain letters, numbers, hyphens, and underscores' };
213
+ }
214
+
215
+ if (slug.length > 100) {
216
+ return { valid: false, error: 'Package name is too long (max 100 characters)' };
217
+ }
218
+
219
+ // Reserved names (system directories and common conflicts)
220
+ const reserved = [
221
+ 'node_modules', 'package', 'src', 'dist', 'build', 'test', 'tests',
222
+ 'lib', 'bin', 'tmp', 'temp', 'cache', 'logs', 'config', 'public',
223
+ 'private', 'assets', 'static', 'vendor', 'packages', 'modules',
224
+ 'con', 'prn', 'aux', 'nul', // Windows reserved names
225
+ 'com1', 'com2', 'com3', 'com4', 'lpt1', 'lpt2', 'lpt3', 'lpt4'
226
+ ];
227
+ if (reserved.includes(slug)) {
228
+ return { valid: false, error: `'${slug}' is a reserved name` };
229
+ }
230
+
231
+ return { valid: true };
232
+ }
233
+
234
+ // Interactive prompts using readline
235
+ function createPrompt() {
236
+ const rl = readline.createInterface({
237
+ input: process.stdin,
238
+ output: process.stdout,
239
+ });
240
+
241
+ let closed = false;
242
+
243
+ // Handle Ctrl+C gracefully
244
+ rl.on('close', () => {
245
+ if (!closed) {
246
+ closed = true;
247
+ console.log('\n\nAborted.');
248
+ process.exit(0);
249
+ }
250
+ });
251
+
252
+ // Also handle SIGINT
253
+ process.on('SIGINT', () => {
254
+ if (!closed) {
255
+ closed = true;
256
+ rl.close();
257
+ }
258
+ });
259
+
260
+ return {
261
+ question: (query, defaultValue) => {
262
+ return new Promise((resolve, reject) => {
263
+ if (closed) {
264
+ reject(new Error('Prompt closed'));
265
+ return;
266
+ }
267
+
268
+ const prompt = defaultValue
269
+ ? `${c.cyan}?${c.reset} ${query} ${c.dim}[${defaultValue}]${c.reset}: `
270
+ : `${c.cyan}?${c.reset} ${query}: `;
271
+
272
+ rl.question(prompt, (answer) => {
273
+ resolve(answer.trim() || defaultValue || '');
274
+ });
275
+ });
276
+ },
277
+ select: (query, choices, defaultIndex = 0) => {
278
+ return new Promise((resolve, reject) => {
279
+ if (closed) {
280
+ reject(new Error('Prompt closed'));
281
+ return;
282
+ }
283
+
284
+ console.log(`\n${c.cyan}?${c.reset} ${query}`);
285
+ choices.forEach((choice, i) => {
286
+ const marker = i === defaultIndex ? `${c.green}❯${c.reset}` : ' ';
287
+ console.log(` ${marker} ${i + 1}. ${c.bold}${choice.name}${c.reset} - ${c.yellow}${choice.description}${c.reset}`);
288
+ });
289
+
290
+ rl.question(`\n Enter choice [1-${choices.length}] ${c.dim}(default: ${defaultIndex + 1})${c.reset}: `, (answer) => {
291
+ const idx = answer.trim() ? parseInt(answer) - 1 : defaultIndex;
292
+ if (idx >= 0 && idx < choices.length) {
293
+ resolve(choices[idx].value);
294
+ } else {
295
+ resolve(choices[defaultIndex].value);
296
+ }
297
+ });
298
+ });
299
+ },
300
+ close: () => {
301
+ closed = true;
302
+ rl.close();
303
+ },
304
+ };
305
+ }
306
+
307
+ // Template content generators
308
+ const templates = {
309
+ // promptline.yaml template
310
+ config: (config) => `# PromptLine Package Configuration
311
+ # Created with: npx create-promptline-app ${escapeYaml(config.slug)}
312
+ # Docs: https://docs.promptlineops.com/packages
313
+
314
+ name: ${escapeYaml(config.displayName)}
315
+ version: "1.0.0"
316
+ description: ${escapeYaml(config.description)}
317
+
318
+ # AI Sources - Connect your prompts and agents
319
+ # Configure these in PromptLine Creator Studio after upload
320
+ ai_sources:
321
+ main:
322
+ type: prompt
323
+ source_id: null # Set in Creator Studio
324
+ role: main
325
+ description: "Main AI prompt for this application"
326
+
327
+ # Data Collections - Your app's data models
328
+ collections:
329
+ submissions:
330
+ display_name: "Submissions"
331
+ description: "Form submissions and user data"
332
+ fields:
333
+ - name: name
334
+ type: string
335
+ label: "Name"
336
+ required: true
337
+ - name: email
338
+ type: email
339
+ label: "Email"
340
+ required: true
341
+ - name: message
342
+ type: text
343
+ label: "Message"
344
+ required: false
345
+ - name: created_at
346
+ type: date
347
+ label: "Created"
348
+ auto: true
349
+ settings:
350
+ public_read: false
351
+ public_write: true
352
+
353
+ # Instance Variables - Configurable per deployment
354
+ variables:
355
+ app_name:
356
+ type: string
357
+ label: "Application Name"
358
+ required: true
359
+ default: ${escapeYaml(config.displayName)}
360
+
361
+ primary_color:
362
+ type: color
363
+ label: "Brand Color"
364
+ default: ${escapeYaml(config.primaryColor)}
365
+
366
+ contact_email:
367
+ type: email
368
+ label: "Contact Email"
369
+ required: true
370
+ default: ${escapeYaml(config.contactEmail)}
371
+
372
+ # Branding defaults
373
+ branding:
374
+ colors:
375
+ primary: "{{variables.primary_color}}"
376
+ secondary: "#10b981"
377
+ typography:
378
+ font: "Inter"
379
+
380
+ # Automated actions
381
+ actions:
382
+ notify_on_submit:
383
+ type: email
384
+ trigger:
385
+ event: on_data_create
386
+ collection: submissions
387
+ config:
388
+ to: "{{variables.contact_email}}"
389
+ subject: "New submission - {{variables.app_name}}"
390
+ body: |
391
+ New submission received:
392
+ Name: {{data.name}}
393
+ Email: {{data.email}}
394
+ Message: {{data.message}}
395
+ `,
396
+
397
+ // Public index page
398
+ publicIndex: (config) => `/**
399
+ * ${escapeJsTemplate(config.displayName)} - Landing Page
400
+ *
401
+ * Public page accessible without authentication.
402
+ * Edit this file to customize your landing page.
403
+ */
404
+ import React, { useState } from 'react'
405
+ import { usePromptLine } from '@promptline/sdk'
406
+
407
+ export default function LandingPage() {
408
+ const { config, submitForm, isLoading } = usePromptLine()
409
+
410
+ const [formData, setFormData] = useState({
411
+ name: '',
412
+ email: '',
413
+ message: ''
414
+ })
415
+ const [submitted, setSubmitted] = useState(false)
416
+ const [error, setError] = useState(null)
417
+
418
+ const handleSubmit = async (e) => {
419
+ e.preventDefault()
420
+ setError(null)
421
+
422
+ try {
423
+ await submitForm('submissions', formData)
424
+ setSubmitted(true)
425
+ setFormData({ name: '', email: '', message: '' })
426
+ } catch (err) {
427
+ setError(err.message || 'Something went wrong')
428
+ }
429
+ }
430
+
431
+ const handleChange = (e) => {
432
+ setFormData(prev => ({
433
+ ...prev,
434
+ [e.target.name]: e.target.value
435
+ }))
436
+ }
437
+
438
+ return (
439
+ <div className="min-h-screen bg-gradient-to-b from-gray-50 to-white">
440
+ {/* Header */}
441
+ <header className="border-b bg-white/80 backdrop-blur-sm sticky top-0 z-10">
442
+ <div className="max-w-5xl mx-auto px-4 py-4 flex items-center justify-between">
443
+ <h1
444
+ className="text-2xl font-bold"
445
+ style={{ color: config.primary_color }}
446
+ >
447
+ {config.app_name}
448
+ </h1>
449
+ <nav className="flex gap-6">
450
+ <a href="#features" className="text-gray-600 hover:text-gray-900">Features</a>
451
+ <a href="#contact" className="text-gray-600 hover:text-gray-900">Contact</a>
452
+ </nav>
453
+ </div>
454
+ </header>
455
+
456
+ {/* Hero Section */}
457
+ <section className="max-w-5xl mx-auto px-4 py-20 text-center">
458
+ <h2 className="text-5xl font-bold text-gray-900 mb-6">
459
+ Welcome to {config.app_name}
460
+ </h2>
461
+ <p className="text-xl text-gray-600 max-w-2xl mx-auto mb-8">
462
+ ${escapeJsTemplate(config.description) || 'Your AI-powered application built with PromptLine.'}
463
+ </p>
464
+ <a
465
+ href="#contact"
466
+ className="inline-block px-8 py-3 text-white font-medium rounded-lg transition-transform hover:scale-105"
467
+ style={{ backgroundColor: config.primary_color }}
468
+ >
469
+ Get Started
470
+ </a>
471
+ </section>
472
+
473
+ {/* Features Section */}
474
+ <section id="features" className="max-w-5xl mx-auto px-4 py-16">
475
+ <h3 className="text-3xl font-bold text-center text-gray-900 mb-12">Features</h3>
476
+ <div className="grid md:grid-cols-3 gap-8">
477
+ {[
478
+ { title: 'AI-Powered', desc: 'Leverage cutting-edge AI for intelligent responses' },
479
+ { title: 'Easy to Use', desc: 'Simple interface designed for everyone' },
480
+ { title: 'Customizable', desc: 'Adapt to your specific needs and branding' }
481
+ ].map((feature, i) => (
482
+ <div key={i} className="bg-white p-6 rounded-xl shadow-sm border">
483
+ <div
484
+ className="w-12 h-12 rounded-lg mb-4 flex items-center justify-center text-white text-xl font-bold"
485
+ style={{ backgroundColor: config.primary_color }}
486
+ >
487
+ {i + 1}
488
+ </div>
489
+ <h4 className="text-lg font-semibold text-gray-900 mb-2">{feature.title}</h4>
490
+ <p className="text-gray-600">{feature.desc}</p>
491
+ </div>
492
+ ))}
493
+ </div>
494
+ </section>
495
+
496
+ {/* Contact Form */}
497
+ <section id="contact" className="max-w-lg mx-auto px-4 py-16">
498
+ <div className="bg-white rounded-2xl shadow-lg p-8">
499
+ <h3 className="text-2xl font-bold text-gray-900 mb-6 text-center">
500
+ Get in Touch
501
+ </h3>
502
+
503
+ {submitted ? (
504
+ <div className="text-center py-8">
505
+ <div
506
+ className="w-16 h-16 rounded-full mx-auto mb-4 flex items-center justify-center"
507
+ style={{ backgroundColor: config.primary_color + '20' }}
508
+ >
509
+ <svg className="w-8 h-8" style={{ color: config.primary_color }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
510
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
511
+ </svg>
512
+ </div>
513
+ <h4 className="text-xl font-medium text-gray-900 mb-2">Thank you!</h4>
514
+ <p className="text-gray-600 mb-4">We'll get back to you soon.</p>
515
+ <button
516
+ onClick={() => setSubmitted(false)}
517
+ className="text-sm underline"
518
+ style={{ color: config.primary_color }}
519
+ >
520
+ Send another message
521
+ </button>
522
+ </div>
523
+ ) : (
524
+ <form onSubmit={handleSubmit} className="space-y-5">
525
+ {error && (
526
+ <div className="p-4 bg-red-50 text-red-700 rounded-lg text-sm">
527
+ {error}
528
+ </div>
529
+ )}
530
+
531
+ <div>
532
+ <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
533
+ Name *
534
+ </label>
535
+ <input
536
+ type="text"
537
+ id="name"
538
+ name="name"
539
+ required
540
+ value={formData.name}
541
+ onChange={handleChange}
542
+ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-offset-0 focus:border-transparent"
543
+ style={{ '--tw-ring-color': config.primary_color }}
544
+ placeholder="Your name"
545
+ />
546
+ </div>
547
+
548
+ <div>
549
+ <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
550
+ Email *
551
+ </label>
552
+ <input
553
+ type="email"
554
+ id="email"
555
+ name="email"
556
+ required
557
+ value={formData.email}
558
+ onChange={handleChange}
559
+ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-offset-0 focus:border-transparent"
560
+ placeholder="you@example.com"
561
+ />
562
+ </div>
563
+
564
+ <div>
565
+ <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
566
+ Message
567
+ </label>
568
+ <textarea
569
+ id="message"
570
+ name="message"
571
+ rows={4}
572
+ value={formData.message}
573
+ onChange={handleChange}
574
+ className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-offset-0 focus:border-transparent resize-none"
575
+ placeholder="How can we help?"
576
+ />
577
+ </div>
578
+
579
+ <button
580
+ type="submit"
581
+ disabled={isLoading}
582
+ className="w-full py-3 px-4 text-white font-medium rounded-lg transition-all disabled:opacity-50 hover:opacity-90"
583
+ style={{ backgroundColor: config.primary_color }}
584
+ >
585
+ {isLoading ? 'Sending...' : 'Send Message'}
586
+ </button>
587
+ </form>
588
+ )}
589
+ </div>
590
+ </section>
591
+
592
+ {/* Footer */}
593
+ <footer className="border-t bg-gray-50">
594
+ <div className="max-w-5xl mx-auto px-4 py-8 text-center text-sm text-gray-500">
595
+ © {new Date().getFullYear()} {config.app_name}. Powered by PromptLine.
596
+ </div>
597
+ </footer>
598
+ </div>
599
+ )
600
+ }
601
+ `,
602
+
603
+ // Private dashboard page
604
+ privateDashboard: (config) => `/**
605
+ * ${escapeJsTemplate(config.displayName)} - Dashboard
606
+ *
607
+ * Private page requiring authentication.
608
+ * Users must be logged in to access this page.
609
+ */
610
+ import React, { useEffect, useState } from 'react'
611
+ import { usePromptLine, Link } from '@promptline/sdk'
612
+
613
+ export default function DashboardPage() {
614
+ const { config, fetchCollection, user, logout } = usePromptLine()
615
+ const [submissions, setSubmissions] = useState([])
616
+ const [stats, setStats] = useState({ total: 0, today: 0, week: 0 })
617
+ const [loading, setLoading] = useState(true)
618
+
619
+ useEffect(() => {
620
+ loadData()
621
+ }, [])
622
+
623
+ const loadData = async () => {
624
+ try {
625
+ const data = await fetchCollection('submissions', {
626
+ orderBy: 'created_at',
627
+ order: 'desc',
628
+ limit: 10
629
+ })
630
+ setSubmissions(data.items)
631
+
632
+ // Calculate stats
633
+ const now = new Date()
634
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
635
+ const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
636
+
637
+ setStats({
638
+ total: data.total,
639
+ today: data.items.filter(s => new Date(s.created_at) >= today).length,
640
+ week: data.items.filter(s => new Date(s.created_at) >= weekAgo).length
641
+ })
642
+ } catch (err) {
643
+ console.error('Failed to load data:', err)
644
+ } finally {
645
+ setLoading(false)
646
+ }
647
+ }
648
+
649
+ return (
650
+ <div className="min-h-screen bg-gray-100">
651
+ {/* Header */}
652
+ <header className="bg-white shadow-sm">
653
+ <div className="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
654
+ <div className="flex items-center gap-8">
655
+ <h1 className="text-xl font-bold" style={{ color: config.primary_color }}>
656
+ {config.app_name}
657
+ </h1>
658
+ <nav className="flex gap-4">
659
+ <Link to="/dashboard" className="font-medium px-3 py-2 rounded-lg" style={{ backgroundColor: config.primary_color + '10', color: config.primary_color }}>
660
+ Dashboard
661
+ </Link>
662
+ <Link to="/settings" className="text-gray-600 hover:text-gray-900 px-3 py-2">
663
+ Settings
664
+ </Link>
665
+ </nav>
666
+ </div>
667
+ <div className="flex items-center gap-4">
668
+ <span className="text-sm text-gray-600">{user?.email}</span>
669
+ <button onClick={logout} className="text-sm text-gray-500 hover:text-gray-700">
670
+ Logout
671
+ </button>
672
+ </div>
673
+ </div>
674
+ </header>
675
+
676
+ {/* Main Content */}
677
+ <main className="max-w-7xl mx-auto px-4 py-8">
678
+ <h2 className="text-2xl font-bold text-gray-900 mb-6">Dashboard</h2>
679
+
680
+ {/* Stats Cards */}
681
+ <div className="grid md:grid-cols-3 gap-6 mb-8">
682
+ <div className="bg-white rounded-xl p-6 shadow-sm">
683
+ <p className="text-sm text-gray-500 uppercase tracking-wide">Total Submissions</p>
684
+ <p className="text-3xl font-bold mt-2" style={{ color: config.primary_color }}>
685
+ {loading ? '...' : stats.total}
686
+ </p>
687
+ </div>
688
+ <div className="bg-white rounded-xl p-6 shadow-sm">
689
+ <p className="text-sm text-gray-500 uppercase tracking-wide">Today</p>
690
+ <p className="text-3xl font-bold mt-2 text-green-600">
691
+ {loading ? '...' : stats.today}
692
+ </p>
693
+ </div>
694
+ <div className="bg-white rounded-xl p-6 shadow-sm">
695
+ <p className="text-sm text-gray-500 uppercase tracking-wide">This Week</p>
696
+ <p className="text-3xl font-bold mt-2 text-blue-600">
697
+ {loading ? '...' : stats.week}
698
+ </p>
699
+ </div>
700
+ </div>
701
+
702
+ {/* Recent Submissions */}
703
+ <div className="bg-white rounded-xl shadow-sm overflow-hidden">
704
+ <div className="px-6 py-4 border-b">
705
+ <h3 className="font-semibold text-gray-900">Recent Submissions</h3>
706
+ </div>
707
+
708
+ {loading ? (
709
+ <div className="p-8 text-center text-gray-500">Loading...</div>
710
+ ) : submissions.length === 0 ? (
711
+ <div className="p-8 text-center text-gray-500">
712
+ <p>No submissions yet</p>
713
+ <p className="text-sm mt-1">New submissions will appear here</p>
714
+ </div>
715
+ ) : (
716
+ <table className="w-full">
717
+ <thead className="bg-gray-50">
718
+ <tr>
719
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
720
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Email</th>
721
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Message</th>
722
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Date</th>
723
+ </tr>
724
+ </thead>
725
+ <tbody className="divide-y divide-gray-200">
726
+ {submissions.map((sub) => (
727
+ <tr key={sub.id} className="hover:bg-gray-50">
728
+ <td className="px-6 py-4 text-sm font-medium text-gray-900">{sub.name}</td>
729
+ <td className="px-6 py-4 text-sm text-gray-600">{sub.email}</td>
730
+ <td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">{sub.message || '-'}</td>
731
+ <td className="px-6 py-4 text-sm text-gray-500">
732
+ {new Date(sub.created_at).toLocaleDateString()}
733
+ </td>
734
+ </tr>
735
+ ))}
736
+ </tbody>
737
+ </table>
738
+ )}
739
+ </div>
740
+ </main>
741
+ </div>
742
+ )
743
+ }
744
+ `,
745
+
746
+ // Blank template - minimal
747
+ blankIndex: (config) => `/**
748
+ * ${escapeJsTemplate(config.displayName)}
749
+ *
750
+ * Your PromptLine app starts here.
751
+ * Edit this file to build your application.
752
+ */
753
+ import React from 'react'
754
+ import { usePromptLine } from '@promptline/sdk'
755
+
756
+ export default function HomePage() {
757
+ const { config, callAI, isLoading } = usePromptLine()
758
+
759
+ return (
760
+ <div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
761
+ <div className="text-center">
762
+ <h1
763
+ className="text-5xl font-bold mb-4"
764
+ style={{ color: config.primary_color }}
765
+ >
766
+ {config.app_name}
767
+ </h1>
768
+ <p className="text-xl text-gray-600 mb-8">
769
+ Your PromptLine app is ready!
770
+ </p>
771
+ <div className="space-x-4">
772
+ <a
773
+ href="https://docs.promptlineops.com"
774
+ target="_blank"
775
+ rel="noopener noreferrer"
776
+ className="inline-block px-6 py-2 border-2 rounded-lg font-medium transition-colors hover:bg-gray-50"
777
+ style={{ borderColor: config.primary_color, color: config.primary_color }}
778
+ >
779
+ Read the Docs
780
+ </a>
781
+ </div>
782
+ </div>
783
+ </div>
784
+ )
785
+ }
786
+ `,
787
+
788
+ // Backend API template
789
+ backendApi: (config) => `"""
790
+ ${escapeJsTemplate(config.displayName)} - Custom API Endpoints
791
+
792
+ Add your custom FastAPI routes here.
793
+ These will be available at /api/custom/<endpoint>
794
+
795
+ Documentation: https://docs.promptlineops.com/packages/backend
796
+ """
797
+
798
+ from fastapi import APIRouter, Depends, HTTPException
799
+ from pydantic import BaseModel
800
+ from typing import Optional
801
+ from promptline import (
802
+ get_instance_config,
803
+ get_collection,
804
+ call_ai_source,
805
+ get_current_user,
806
+ require_auth
807
+ )
808
+
809
+ router = APIRouter()
810
+
811
+
812
+ class AnalyzeRequest(BaseModel):
813
+ text: str
814
+ options: Optional[dict] = None
815
+
816
+
817
+ @router.get("/health")
818
+ async def health_check():
819
+ """Health check endpoint"""
820
+ config = await get_instance_config()
821
+ return {
822
+ "status": "healthy",
823
+ "app": config.get("app_name", "Unknown")
824
+ }
825
+
826
+
827
+ @router.post("/analyze")
828
+ async def analyze_text(request: AnalyzeRequest):
829
+ """
830
+ Analyze text using the connected AI source.
831
+
832
+ This is a public endpoint - no authentication required.
833
+ """
834
+ if not request.text.strip():
835
+ raise HTTPException(status_code=400, detail="Text is required")
836
+
837
+ try:
838
+ result = await call_ai_source("main", {
839
+ "text": request.text,
840
+ "options": request.options or {}
841
+ })
842
+ return {"success": True, "result": result}
843
+ except Exception as e:
844
+ raise HTTPException(status_code=500, detail=str(e))
845
+
846
+
847
+ @router.get("/stats", dependencies=[Depends(require_auth)])
848
+ async def get_stats():
849
+ """
850
+ Get submission statistics.
851
+
852
+ Requires authentication.
853
+ """
854
+ collection = await get_collection("submissions")
855
+ items = await collection.find({})
856
+
857
+ return {
858
+ "total": len(items),
859
+ "latest": items[0] if items else None
860
+ }
861
+
862
+
863
+ @router.get("/me", dependencies=[Depends(require_auth)])
864
+ async def get_current_user_info():
865
+ """Get current authenticated user info"""
866
+ user = await get_current_user()
867
+ return {
868
+ "id": user.id,
869
+ "email": user.email,
870
+ "name": user.name
871
+ }
872
+ `,
873
+
874
+ // Settings page
875
+ privateSettings: (config) => `/**
876
+ * ${escapeJsTemplate(config.displayName)} - Settings
877
+ *
878
+ * User settings and preferences.
879
+ */
880
+ import React from 'react'
881
+ import { usePromptLine, Link } from '@promptline/sdk'
882
+
883
+ export default function SettingsPage() {
884
+ const { config, user } = usePromptLine()
885
+
886
+ return (
887
+ <div className="min-h-screen bg-gray-100">
888
+ <header className="bg-white shadow-sm">
889
+ <div className="max-w-7xl mx-auto px-4 py-4 flex items-center gap-8">
890
+ <h1 className="text-xl font-bold" style={{ color: config.primary_color }}>
891
+ {config.app_name}
892
+ </h1>
893
+ <nav className="flex gap-4">
894
+ <Link to="/dashboard" className="text-gray-600 hover:text-gray-900 px-3 py-2">
895
+ Dashboard
896
+ </Link>
897
+ <Link to="/settings" className="font-medium px-3 py-2 rounded-lg" style={{ backgroundColor: config.primary_color + '10', color: config.primary_color }}>
898
+ Settings
899
+ </Link>
900
+ </nav>
901
+ </div>
902
+ </header>
903
+
904
+ <main className="max-w-3xl mx-auto px-4 py-8">
905
+ <h2 className="text-2xl font-bold text-gray-900 mb-6">Settings</h2>
906
+
907
+ <div className="bg-white rounded-xl shadow-sm divide-y">
908
+ <div className="p-6">
909
+ <h3 className="font-medium text-gray-900 mb-4">Profile</h3>
910
+ <div className="space-y-4">
911
+ <div>
912
+ <label className="block text-sm text-gray-500 mb-1">Email</label>
913
+ <p className="text-gray-900">{user?.email}</p>
914
+ </div>
915
+ <div>
916
+ <label className="block text-sm text-gray-500 mb-1">Name</label>
917
+ <p className="text-gray-900">{user?.name || 'Not set'}</p>
918
+ </div>
919
+ </div>
920
+ </div>
921
+
922
+ <div className="p-6">
923
+ <h3 className="font-medium text-gray-900 mb-4">Preferences</h3>
924
+ <p className="text-sm text-gray-500">
925
+ Additional settings coming soon.
926
+ </p>
927
+ </div>
928
+ </div>
929
+ </main>
930
+ </div>
931
+ )
932
+ }
933
+ `,
934
+
935
+ // README
936
+ readme: (config) => `# ${config.displayName}
937
+
938
+ ${config.description}
939
+
940
+ ## Getting Started
941
+
942
+ This is a PromptLine package created with \`create-promptline-app\`.
943
+
944
+ ### Project Structure
945
+
946
+ \`\`\`
947
+ ${config.slug}/
948
+ ├── public/ # Public pages (no auth required)
949
+ │ └── index.tsx # Landing page
950
+ ├── private/ # Protected pages (auth required)
951
+ │ ├── dashboard.tsx # Main dashboard
952
+ │ └── settings.tsx # User settings
953
+ ├── backend/ # Custom API endpoints
954
+ │ └── api.py # FastAPI routes
955
+ ├── assets/ # Static files
956
+ ├── promptline.yaml # Package configuration
957
+ └── README.md
958
+ \`\`\`
959
+
960
+ ### Development
961
+
962
+ 1. Edit your pages in \`public/\` and \`private/\`
963
+ 2. Add custom API endpoints in \`backend/api.py\`
964
+ 3. Configure AI sources in \`promptline.yaml\`
965
+
966
+ ### Deployment
967
+
968
+ 1. Create a ZIP of this directory
969
+ 2. Upload to [PromptLine Creator Studio](https://app.promptlineops.com/apps/creator)
970
+ 3. Connect your AI sources
971
+ 4. Configure instance variables
972
+ 5. Publish!
973
+
974
+ ### SDK Usage
975
+
976
+ \`\`\`tsx
977
+ import { usePromptLine } from '@promptline/sdk'
978
+
979
+ function MyComponent() {
980
+ const {
981
+ config, // Instance variables (app_name, primary_color, etc.)
982
+ submitForm, // Submit data to a collection
983
+ fetchCollection, // Fetch collection data
984
+ callAI, // Call connected AI source
985
+ user, // Current user (private pages only)
986
+ isLoading
987
+ } = usePromptLine()
988
+
989
+ // Example: Call AI
990
+ const result = await callAI('main', { text: 'Hello!' })
991
+
992
+ // Example: Submit form
993
+ await submitForm('submissions', { name: 'John', email: 'john@example.com' })
994
+ }
995
+ \`\`\`
996
+
997
+ ## Documentation
998
+
999
+ - [PromptLine Docs](https://docs.promptlineops.com)
1000
+ - [Package Development](https://docs.promptlineops.com/packages)
1001
+ - [SDK Reference](https://docs.promptlineops.com/sdk)
1002
+
1003
+ ---
1004
+
1005
+ Created with [create-promptline-app](https://npmjs.com/package/create-promptline-app)
1006
+ `,
1007
+
1008
+ // .gitignore
1009
+ gitignore: () => `# Dependencies
1010
+ node_modules/
1011
+
1012
+ # Dev server
1013
+ .promptline-dev/
1014
+
1015
+ # Build
1016
+ dist/
1017
+ build/
1018
+ .next/
1019
+
1020
+ # Environment
1021
+ .env
1022
+ .env.local
1023
+ .env.*
1024
+
1025
+ # IDE
1026
+ .idea/
1027
+ .vscode/
1028
+ *.swp
1029
+
1030
+ # OS
1031
+ .DS_Store
1032
+ Thumbs.db
1033
+
1034
+ # Logs
1035
+ *.log
1036
+
1037
+ # Package archives
1038
+ *.zip
1039
+ *.tar.gz
1040
+ `,
1041
+
1042
+ // package.json for local dev
1043
+ packageJson: (config) => JSON.stringify({
1044
+ "name": config.slug,
1045
+ "version": "1.0.0",
1046
+ "private": true,
1047
+ "type": "module",
1048
+ "scripts": {
1049
+ "dev": "vite",
1050
+ "build": "vite build",
1051
+ "preview": "vite preview"
1052
+ },
1053
+ "dependencies": {
1054
+ "react": "^18.2.0",
1055
+ "react-dom": "^18.2.0",
1056
+ "react-router-dom": "^6.20.0"
1057
+ },
1058
+ "devDependencies": {
1059
+ "vite": "^5.0.0",
1060
+ "@vitejs/plugin-react": "^4.2.0"
1061
+ }
1062
+ }, null, 2),
1063
+
1064
+ // vite.config.js
1065
+ viteConfig: () => `import { defineConfig } from 'vite'
1066
+ import react from '@vitejs/plugin-react'
1067
+ import path from 'path'
1068
+
1069
+ export default defineConfig({
1070
+ plugins: [react()],
1071
+ resolve: {
1072
+ alias: {
1073
+ '@promptline/sdk': path.resolve(__dirname, 'dev/sdk-mock.jsx')
1074
+ }
1075
+ },
1076
+ server: {
1077
+ port: 5173,
1078
+ host: 'localhost',
1079
+ open: true,
1080
+ strictPort: false,
1081
+ proxy: {
1082
+ // Proxy API calls to avoid CORS issues in development
1083
+ '/api/promptline-local': {
1084
+ target: 'https://app.local.promptlineops.com',
1085
+ changeOrigin: true,
1086
+ rewrite: (path) => path.replace(/^\\/api\\/promptline-local/, '/api/v1/live'),
1087
+ secure: true
1088
+ },
1089
+ '/api/promptline': {
1090
+ target: 'https://app.promptlineops.com',
1091
+ changeOrigin: true,
1092
+ rewrite: (path) => path.replace(/^\\/api\\/promptline/, '/api/v1/live'),
1093
+ secure: true
1094
+ }
1095
+ }
1096
+ }
1097
+ })
1098
+ `,
1099
+
1100
+ // SDK mock for local development with real API support
1101
+ sdkMock: (config) => {
1102
+ const mockConfig = {
1103
+ app_name: config.displayName,
1104
+ primary_color: config.primaryColor,
1105
+ contact_email: config.contactEmail
1106
+ };
1107
+ return `/**
1108
+ * PromptLine SDK Mock for Local Development
1109
+ * Supports real API calls when configured via /_dev page
1110
+ */
1111
+ import React, { createContext, useContext, useState } from 'react'
1112
+ import { Link as RouterLink } from 'react-router-dom'
1113
+
1114
+ const mockConfig = ${JSON.stringify(mockConfig, null, 2)}
1115
+
1116
+ const mockUser = {
1117
+ id: 'dev-user-123',
1118
+ email: 'dev@example.com',
1119
+ name: 'Dev User'
1120
+ }
1121
+
1122
+ // Get PromptLine API config from localStorage
1123
+ function getAPIConfig() {
1124
+ try {
1125
+ return JSON.parse(localStorage.getItem('promptline_dev_config') || 'null')
1126
+ } catch { return null }
1127
+ }
1128
+
1129
+ // Call PromptLine API (sync mode)
1130
+ async function callPromptLineAPI(config, input) {
1131
+ const { endpoint, apiKey } = config
1132
+
1133
+ // Add ?mode=sync for synchronous response
1134
+ const url = endpoint.includes('?') ? endpoint + '&mode=sync' : endpoint + '?mode=sync'
1135
+
1136
+ const res = await fetch(url, {
1137
+ method: 'POST',
1138
+ headers: {
1139
+ 'Content-Type': 'application/json',
1140
+ ...(apiKey ? { 'X-API-Key': apiKey } : {})
1141
+ },
1142
+ body: JSON.stringify({
1143
+ input: typeof input === 'string' ? { text: input } : input,
1144
+ variables: {}
1145
+ })
1146
+ })
1147
+ const data = await res.json()
1148
+ if (!res.ok || data.error) throw new Error(data.detail || data.error?.message || data.error || res.statusText)
1149
+ // Sync mode returns data.data.output or data.data.response
1150
+ const output = data.data?.output || data.data?.response || data.output || data.response
1151
+ return { response: typeof output === 'string' ? output : JSON.stringify(output) }
1152
+ }
1153
+
1154
+ const PromptLineContext = createContext(null)
1155
+
1156
+ export function PromptLineProvider({ children }) {
1157
+ const [isLoading, setIsLoading] = useState(false)
1158
+
1159
+ const value = {
1160
+ config: mockConfig,
1161
+ user: mockUser,
1162
+ isLoading,
1163
+ submitForm: async (collection, data) => {
1164
+ console.log('[PromptLine SDK] submitForm:', collection, data)
1165
+ alert('Form submitted to "' + collection + '"\\n\\nData: ' + JSON.stringify(data, null, 2))
1166
+ return { success: true, id: 'mock-' + Date.now() }
1167
+ },
1168
+ fetchCollection: async (collection, query) => {
1169
+ console.log('[PromptLine SDK] fetchCollection:', collection, query)
1170
+ return { items: [], total: 0 }
1171
+ },
1172
+ callAI: async (sourceId, input) => {
1173
+ console.log('[PromptLine SDK] callAI:', sourceId, input)
1174
+ const config = getAPIConfig()
1175
+
1176
+ if (config && config.endpoint) {
1177
+ console.log('[PromptLine SDK] Using PromptLine API:', config.endpoint)
1178
+ try {
1179
+ return await callPromptLineAPI(config, input)
1180
+ } catch (err) {
1181
+ console.error('[PromptLine SDK] API Error:', err)
1182
+ return { response: '[API Error] ' + err.message, error: true }
1183
+ }
1184
+ }
1185
+
1186
+ console.log('[PromptLine SDK] No config found, using mock. Configure at /_dev')
1187
+ return { response: '[Mock] Configure your PromptLine endpoint at /_dev' }
1188
+ }
1189
+ }
1190
+
1191
+ return (
1192
+ <PromptLineContext.Provider value={value}>
1193
+ {children}
1194
+ </PromptLineContext.Provider>
1195
+ )
1196
+ }
1197
+
1198
+ export function usePromptLine() {
1199
+ const context = useContext(PromptLineContext)
1200
+ if (!context) {
1201
+ return {
1202
+ config: mockConfig,
1203
+ user: mockUser,
1204
+ isLoading: false,
1205
+ submitForm: async (c, d) => { console.log('[PromptLine SDK] submitForm:', c, d); return { success: true } },
1206
+ fetchCollection: async (c, q) => { console.log('[PromptLine SDK] fetchCollection:', c, q); return { items: [], total: 0 } },
1207
+ callAI: async (s, i) => { console.log('[PromptLine SDK] callAI:', s, i); return { response: '[Mock] Configure at /_dev' } }
1208
+ }
1209
+ }
1210
+ return context
1211
+ }
1212
+
1213
+ export const Link = ({ to, children, ...props }) => (
1214
+ <RouterLink to={to} {...props}>{children}</RouterLink>
1215
+ )
1216
+
1217
+ export default { usePromptLine, PromptLineProvider, Link }
1218
+ `},
1219
+
1220
+ // Dev admin page for configuring PromptLine endpoint
1221
+ devAdmin: () => `/**
1222
+ * PromptLine Dev Admin - Endpoint Configuration
1223
+ *
1224
+ * This page is ONLY for local development.
1225
+ * Configure your PromptLine endpoint here to test with real API.
1226
+ *
1227
+ * NEVER commit API keys to git!
1228
+ */
1229
+ import React, { useState, useEffect } from 'react'
1230
+ import { Link } from 'react-router-dom'
1231
+
1232
+ export default function DevAdminPage() {
1233
+ const [endpoint, setEndpoint] = useState('')
1234
+ const [apiKey, setApiKey] = useState('')
1235
+ const [saved, setSaved] = useState(false)
1236
+ const [testInput, setTestInput] = useState('Hello! What is 2+2?')
1237
+ const [testResult, setTestResult] = useState('')
1238
+ const [testing, setTesting] = useState(false)
1239
+ const [status, setStatus] = useState('') // 'connected' | 'error' | 'testing' | ''
1240
+ const [logs, setLogs] = useState([])
1241
+
1242
+ const addLog = (type, message, details = null) => {
1243
+ const timestamp = new Date().toLocaleTimeString()
1244
+ setLogs(prev => [...prev.slice(-50), { timestamp, type, message, details }])
1245
+ }
1246
+
1247
+ useEffect(() => {
1248
+ const config = JSON.parse(localStorage.getItem('promptline_dev_config') || 'null')
1249
+ if (config) {
1250
+ setEndpoint(config.endpoint || '')
1251
+ setApiKey(config.apiKey || '')
1252
+ setSaved(true)
1253
+ }
1254
+ }, [])
1255
+
1256
+ const saveConfig = () => {
1257
+ if (!endpoint) return
1258
+ const config = { endpoint, apiKey }
1259
+ localStorage.setItem('promptline_dev_config', JSON.stringify(config))
1260
+ setSaved(true)
1261
+ addLog('info', 'Configuration saved')
1262
+ }
1263
+
1264
+ const clearConfig = () => {
1265
+ localStorage.removeItem('promptline_dev_config')
1266
+ setEndpoint('')
1267
+ setApiKey('')
1268
+ setSaved(false)
1269
+ setStatus('')
1270
+ addLog('info', 'Configuration cleared')
1271
+ }
1272
+
1273
+ // Convert PromptLine URLs to use local proxy (avoids CORS in development)
1274
+ const toProxyUrl = (url) => {
1275
+ // Match app.local.promptlineops.com (local dev environment)
1276
+ const localMatch = url.match(/https?:\\/\\/app\\.local\\.promptlineops\\.com\\/api\\/v1\\/live\\/([^?]+)/)
1277
+ if (localMatch) {
1278
+ return '/api/promptline-local/' + localMatch[1]
1279
+ }
1280
+ // Match app.promptlineops.com (production environment)
1281
+ const prodMatch = url.match(/https?:\\/\\/app\\.promptlineops\\.com\\/api\\/v1\\/live\\/([^?]+)/)
1282
+ if (prodMatch) {
1283
+ return '/api/promptline/' + prodMatch[1]
1284
+ }
1285
+ return url // Non-PromptLine URLs pass through unchanged
1286
+ }
1287
+
1288
+ const testConnection = async () => {
1289
+ if (!endpoint) return
1290
+ setStatus('testing')
1291
+ const proxyUrl = toProxyUrl(endpoint)
1292
+ const url = proxyUrl.includes('?') ? proxyUrl + '&mode=sync' : proxyUrl + '?mode=sync'
1293
+ addLog('info', 'Testing connection...', { endpoint: url, original: endpoint })
1294
+
1295
+ const startTime = Date.now()
1296
+ try {
1297
+ const res = await fetch(url, {
1298
+ method: 'POST',
1299
+ headers: {
1300
+ 'Content-Type': 'application/json',
1301
+ ...(apiKey ? { 'X-API-Key': apiKey } : {})
1302
+ },
1303
+ body: JSON.stringify({ input: { text: 'test' }, variables: {} })
1304
+ })
1305
+ const duration = Date.now() - startTime
1306
+
1307
+ if (res.ok) {
1308
+ setStatus('connected')
1309
+ addLog('success', 'Connected (' + duration + 'ms)', { status: res.status })
1310
+ } else {
1311
+ const data = await res.json().catch(() => ({}))
1312
+ setStatus('error')
1313
+ addLog('error', res.status + ' ' + res.statusText + ' (' + duration + 'ms)', data)
1314
+ }
1315
+ } catch (err) {
1316
+ const duration = Date.now() - startTime
1317
+ setStatus('error')
1318
+ addLog('error', 'Network error (' + duration + 'ms)', {
1319
+ message: err.message,
1320
+ hint: err.message.includes('Failed to fetch') ? 'CORS issue - server needs Access-Control-Allow-Origin header' : null
1321
+ })
1322
+ }
1323
+ }
1324
+
1325
+ const testAI = async () => {
1326
+ if (!endpoint) return
1327
+ setTesting(true)
1328
+ setTestResult('')
1329
+ const proxyUrl = toProxyUrl(endpoint)
1330
+ const url = proxyUrl.includes('?') ? proxyUrl + '&mode=sync' : proxyUrl + '?mode=sync'
1331
+ addLog('request', 'POST ' + url, { input: testInput })
1332
+
1333
+ const startTime = Date.now()
1334
+ try {
1335
+ const res = await fetch(url, {
1336
+ method: 'POST',
1337
+ headers: {
1338
+ 'Content-Type': 'application/json',
1339
+ ...(apiKey ? { 'X-API-Key': apiKey } : {})
1340
+ },
1341
+ body: JSON.stringify({ input: { text: testInput }, variables: {} })
1342
+ })
1343
+ const data = await res.json()
1344
+ const duration = Date.now() - startTime
1345
+
1346
+ if (!res.ok || data.error) {
1347
+ const errMsg = data.detail || data.error?.message || data.error || res.statusText
1348
+ setTestResult('Error: ' + errMsg)
1349
+ addLog('error', res.status + ' (' + duration + 'ms)', data)
1350
+ } else {
1351
+ // Sync mode returns data.data.output or data.data.response
1352
+ const output = data.data?.output || data.data?.response || data.output || data.response || JSON.stringify(data, null, 2)
1353
+ const outputStr = typeof output === 'string' ? output : JSON.stringify(output, null, 2)
1354
+ setTestResult(outputStr)
1355
+ addLog('success', '200 OK (' + duration + 'ms)', { output: outputStr.substring(0, 200) })
1356
+ }
1357
+ } catch (err) {
1358
+ setTestResult('Error: ' + err.message)
1359
+ addLog('error', 'Network error', { message: err.message })
1360
+ }
1361
+ setTesting(false)
1362
+ }
1363
+
1364
+ return (
1365
+ <div className="min-h-screen bg-gray-900 text-white p-8">
1366
+ <div className="max-w-2xl mx-auto">
1367
+ <div className="flex items-center justify-between mb-8">
1368
+ <div>
1369
+ <h1 className="text-3xl font-bold text-yellow-400">/_dev</h1>
1370
+ <p className="text-gray-400 mt-1">PromptLine Endpoint (Local Only)</p>
1371
+ </div>
1372
+ <Link to="/" className="px-4 py-2 bg-gray-700 rounded hover:bg-gray-600">
1373
+ Back to App
1374
+ </Link>
1375
+ </div>
1376
+
1377
+ <div className="bg-yellow-900/30 border border-yellow-600 rounded-lg p-4 mb-8">
1378
+ <p className="text-yellow-300 text-sm">
1379
+ Development Only - API keys stored in localStorage. Never commit to git.
1380
+ </p>
1381
+ </div>
1382
+
1383
+ {/* Configuration */}
1384
+ <div className="bg-gray-800 rounded-lg p-6 mb-8">
1385
+ <div className="flex items-center justify-between mb-4">
1386
+ <h2 className="text-xl font-semibold">Configuration</h2>
1387
+ {saved && (
1388
+ <span className="px-2 py-1 bg-green-600/30 text-green-400 text-xs rounded">Saved</span>
1389
+ )}
1390
+ </div>
1391
+
1392
+ <div className="space-y-4">
1393
+ <div>
1394
+ <label className="block text-sm text-gray-400 mb-1">Endpoint URL</label>
1395
+ <input
1396
+ value={endpoint}
1397
+ onChange={(e) => { setEndpoint(e.target.value); setSaved(false) }}
1398
+ className="w-full bg-gray-700 p-3 rounded text-white font-mono text-sm"
1399
+ placeholder="https://app.promptlineops.com/api/v1/live/..."
1400
+ />
1401
+ <p className="text-xs text-gray-500 mt-1">Copy the Live API URL from Creator Studio</p>
1402
+ </div>
1403
+
1404
+ <div>
1405
+ <label className="block text-sm text-gray-400 mb-1">API Key</label>
1406
+ <input
1407
+ type="password"
1408
+ value={apiKey}
1409
+ onChange={(e) => { setApiKey(e.target.value); setSaved(false) }}
1410
+ className="w-full bg-gray-700 p-3 rounded text-white font-mono text-sm"
1411
+ placeholder="Your API key..."
1412
+ />
1413
+ <p className="text-xs text-gray-500 mt-1">Get your API key from /api-keys</p>
1414
+ </div>
1415
+
1416
+ <div className="flex gap-3">
1417
+ <button
1418
+ onClick={saveConfig}
1419
+ disabled={!endpoint}
1420
+ className="px-6 py-2 bg-indigo-600 rounded font-medium hover:bg-indigo-500 disabled:opacity-50"
1421
+ >
1422
+ Save
1423
+ </button>
1424
+ <button
1425
+ onClick={testConnection}
1426
+ disabled={!endpoint || status === 'testing'}
1427
+ className="px-6 py-2 bg-gray-600 rounded font-medium hover:bg-gray-500 disabled:opacity-50 flex items-center gap-2"
1428
+ >
1429
+ {status === 'testing' ? 'Testing...' : 'Test Connection'}
1430
+ {status === 'connected' && <span className="text-green-400">●</span>}
1431
+ {status === 'error' && <span className="text-red-400">●</span>}
1432
+ </button>
1433
+ {saved && (
1434
+ <button
1435
+ onClick={clearConfig}
1436
+ className="px-4 py-2 bg-red-600/30 text-red-400 rounded hover:bg-red-600/50"
1437
+ >
1438
+ Clear
1439
+ </button>
1440
+ )}
1441
+ </div>
1442
+ </div>
1443
+ </div>
1444
+
1445
+ {/* Test AI */}
1446
+ {saved && (
1447
+ <div className="bg-gray-800 rounded-lg p-6 mb-8">
1448
+ <h2 className="text-xl font-semibold mb-4">Test AI</h2>
1449
+ <textarea
1450
+ value={testInput}
1451
+ onChange={(e) => setTestInput(e.target.value)}
1452
+ className="w-full bg-gray-700 p-3 rounded mb-4 text-white"
1453
+ rows={3}
1454
+ placeholder="Enter test input..."
1455
+ />
1456
+ <button
1457
+ onClick={testAI}
1458
+ disabled={testing}
1459
+ className="px-6 py-2 bg-green-600 rounded font-medium hover:bg-green-500 disabled:opacity-50"
1460
+ >
1461
+ {testing ? 'Sending...' : 'Send Request'}
1462
+ </button>
1463
+ {testResult && (
1464
+ <div className="mt-4 p-4 bg-gray-700 rounded">
1465
+ <p className="text-sm text-gray-400 mb-2">Response:</p>
1466
+ <pre className="whitespace-pre-wrap text-green-300">{testResult}</pre>
1467
+ </div>
1468
+ )}
1469
+ </div>
1470
+ )}
1471
+
1472
+ {/* Logs */}
1473
+ <div className="bg-gray-800 rounded-lg p-6">
1474
+ <div className="flex items-center justify-between mb-4">
1475
+ <h2 className="text-xl font-semibold">Logs</h2>
1476
+ <button onClick={() => setLogs([])} className="px-3 py-1 bg-gray-700 rounded text-sm hover:bg-gray-600">
1477
+ Clear
1478
+ </button>
1479
+ </div>
1480
+ <div className="bg-gray-900 rounded p-4 h-48 overflow-y-auto font-mono text-sm">
1481
+ {logs.length === 0 ? (
1482
+ <p className="text-gray-500">No logs yet</p>
1483
+ ) : (
1484
+ logs.map((log, i) => (
1485
+ <div key={i} className="mb-2 border-b border-gray-800 pb-2">
1486
+ <div className="flex items-center gap-2">
1487
+ <span className="text-gray-500">{log.timestamp}</span>
1488
+ <span className={
1489
+ log.type === 'success' ? 'text-green-400' :
1490
+ log.type === 'error' ? 'text-red-400' :
1491
+ log.type === 'request' ? 'text-blue-400' :
1492
+ 'text-yellow-400'
1493
+ }>[{log.type.toUpperCase()}]</span>
1494
+ <span className="text-gray-200">{log.message}</span>
1495
+ </div>
1496
+ {log.details && (
1497
+ <pre className="text-xs text-gray-400 mt-1 ml-4 overflow-x-auto">
1498
+ {JSON.stringify(log.details, null, 2)}
1499
+ </pre>
1500
+ )}
1501
+ </div>
1502
+ ))
1503
+ )}
1504
+ </div>
1505
+ </div>
1506
+
1507
+ {/* Usage */}
1508
+ <div className="mt-8 p-6 bg-gray-800 rounded-lg">
1509
+ <h2 className="text-xl font-semibold mb-4">Usage in Your Code</h2>
1510
+ <pre className="bg-gray-900 p-4 rounded text-sm overflow-x-auto">
1511
+ {$BACKTICK}const { callAI } = usePromptLine()
1512
+
1513
+ const result = await callAI('main', { text: 'Hello!' })
1514
+ console.log(result.response)$BACKTICK}
1515
+ </pre>
1516
+ </div>
1517
+ </div>
1518
+ </div>
1519
+ )
1520
+ }
1521
+ `.replace(/\$BACKTICK/g, '`'),
1522
+
1523
+ // index.html for Vite
1524
+ indexHtml: (config) => `<!DOCTYPE html>
1525
+ <html lang="en">
1526
+ <head>
1527
+ <meta charset="UTF-8" />
1528
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1529
+ <title>${escapeJsTemplate(config.displayName)}</title>
1530
+ <script src="https://cdn.tailwindcss.com"></script>
1531
+ </head>
1532
+ <body>
1533
+ <div id="root"></div>
1534
+ <script type="module" src="/dev/main.jsx"></script>
1535
+ </body>
1536
+ </html>
1537
+ `,
1538
+
1539
+ // main.jsx entry point
1540
+ mainJsx: (config, pages) => {
1541
+ const imports = [];
1542
+ const routes = [];
1543
+
1544
+ pages.public.forEach(name => {
1545
+ const componentName = 'Public' + name.charAt(0).toUpperCase() + name.slice(1).replace(/-./g, x => x[1].toUpperCase());
1546
+ imports.push("import " + componentName + " from '../public/" + name + ".tsx'");
1547
+ const routePath = name === 'index' ? '/' : '/' + name;
1548
+ routes.push(" { path: '" + routePath + "', element: <" + componentName + " /> }");
1549
+ });
1550
+
1551
+ pages.private.forEach(name => {
1552
+ const componentName = 'Private' + name.charAt(0).toUpperCase() + name.slice(1).replace(/-./g, x => x[1].toUpperCase());
1553
+ imports.push("import " + componentName + " from '../private/" + name + ".tsx'");
1554
+ routes.push(" { path: '/" + name + "', element: <" + componentName + " /> }");
1555
+ });
1556
+
1557
+ // Add dev admin route
1558
+ imports.push("import DevAdmin from './dev-admin.jsx'");
1559
+ routes.push(" { path: '/_dev', element: <DevAdmin /> }");
1560
+
1561
+ return `/**
1562
+ * Development Entry Point
1563
+ * Auto-generated by create-promptline-app
1564
+ */
1565
+ import React from 'react'
1566
+ import ReactDOM from 'react-dom/client'
1567
+ import { createBrowserRouter, RouterProvider } from 'react-router-dom'
1568
+ import { PromptLineProvider } from './sdk-mock.jsx'
1569
+
1570
+ ${imports.join('\n')}
1571
+
1572
+ const router = createBrowserRouter([
1573
+ ${routes.join(',\n')}
1574
+ ])
1575
+
1576
+ ReactDOM.createRoot(document.getElementById('root')).render(
1577
+ <React.StrictMode>
1578
+ <PromptLineProvider>
1579
+ <RouterProvider router={router} />
1580
+ </PromptLineProvider>
1581
+ </React.StrictMode>
1582
+ )
1583
+ `},
1584
+ };
1585
+
1586
+ // Create package function
1587
+ async function createPackage(name, options = {}) {
1588
+ printLogo();
1589
+
1590
+ // Handle absolute paths vs relative names
1591
+ let targetDir, slug;
1592
+ if (path.isAbsolute(name)) {
1593
+ // Absolute path provided
1594
+ targetDir = name;
1595
+ slug = slugify(path.basename(name));
1596
+ } else {
1597
+ // Relative name provided
1598
+ slug = slugify(name);
1599
+ targetDir = path.join(process.cwd(), slug);
1600
+ }
1601
+
1602
+ // Validate package name (security + UX)
1603
+ const validation = validatePackageName(slug);
1604
+ if (!validation.valid) {
1605
+ error(validation.error);
1606
+ process.exit(1);
1607
+ }
1608
+
1609
+ if (fs.existsSync(targetDir)) {
1610
+ error(`Directory '${slug}' already exists`);
1611
+ process.exit(1);
1612
+ }
1613
+
1614
+ console.log(`${c.bold}Creating new PromptLine app: ${c.cyan}${name}${c.reset}\n`);
1615
+
1616
+ // Configuration
1617
+ let config = {
1618
+ name,
1619
+ slug,
1620
+ displayName: name.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
1621
+ description: 'My PromptLine application',
1622
+ primaryColor: '#6366f1',
1623
+ contactEmail: 'contact@example.com',
1624
+ preset: options.preset || 'contact',
1625
+ };
1626
+
1627
+ // Interactive mode
1628
+ if (!options.yes) {
1629
+ const prompt = createPrompt();
1630
+
1631
+ try {
1632
+ // Display name with validation
1633
+ let displayNameValid = false;
1634
+ while (!displayNameValid) {
1635
+ const input = await prompt.question('Display name', config.displayName);
1636
+ const validation = validateString(input, 'Display name', 100);
1637
+ if (validation.valid) {
1638
+ config.displayName = validation.sanitized;
1639
+ displayNameValid = true;
1640
+ } else {
1641
+ error(validation.error);
1642
+ }
1643
+ }
1644
+
1645
+ // Description with validation
1646
+ let descriptionValid = false;
1647
+ while (!descriptionValid) {
1648
+ const input = await prompt.question('Description', config.description);
1649
+ const validation = validateString(input, 'Description', 500);
1650
+ if (validation.valid) {
1651
+ config.description = validation.sanitized;
1652
+ descriptionValid = true;
1653
+ } else {
1654
+ error(validation.error);
1655
+ }
1656
+ }
1657
+
1658
+ // Primary color with validation
1659
+ let colorValid = false;
1660
+ while (!colorValid) {
1661
+ const input = await prompt.question('Primary color (hex)', config.primaryColor);
1662
+ const validation = validateHexColor(input);
1663
+ if (validation.valid) {
1664
+ config.primaryColor = validation.normalized;
1665
+ colorValid = true;
1666
+ } else {
1667
+ error(validation.error);
1668
+ }
1669
+ }
1670
+
1671
+ // Contact email with validation
1672
+ let emailValid = false;
1673
+ while (!emailValid) {
1674
+ const input = await prompt.question('Contact email', config.contactEmail);
1675
+ const validation = validateEmail(input);
1676
+ if (validation.valid) {
1677
+ config.contactEmail = input.trim();
1678
+ emailValid = true;
1679
+ } else {
1680
+ error(validation.error);
1681
+ }
1682
+ }
1683
+
1684
+ if (!options.preset) {
1685
+ const presets = [
1686
+ { name: 'Contact Form', description: 'Landing + form + dashboard', value: 'contact' },
1687
+ { name: 'SaaS', description: 'Full app with auth & billing', value: 'saas' },
1688
+ { name: 'API', description: 'Backend-focused, minimal UI', value: 'api' },
1689
+ { name: 'Blank', description: 'Empty template', value: 'blank' },
1690
+ ];
1691
+ config.preset = await prompt.select('Select a template:', presets);
1692
+ }
1693
+
1694
+ prompt.close();
1695
+ } catch (err) {
1696
+ prompt.close();
1697
+ throw err;
1698
+ }
1699
+ }
1700
+
1701
+ console.log(`\n${c.bold}Creating package structure...${c.reset}\n`);
1702
+
1703
+ try {
1704
+ // Create directories
1705
+ fs.mkdirSync(targetDir, { recursive: true });
1706
+ fs.mkdirSync(path.join(targetDir, 'public'));
1707
+ fs.mkdirSync(path.join(targetDir, 'private'));
1708
+ fs.mkdirSync(path.join(targetDir, 'backend'));
1709
+ fs.mkdirSync(path.join(targetDir, 'assets'));
1710
+
1711
+ success(`Created ${path.basename(targetDir)}/`);
1712
+ success('Created public/');
1713
+ success('Created private/');
1714
+ success('Created backend/');
1715
+ success('Created assets/');
1716
+
1717
+ // Create files based on preset
1718
+ if (config.preset === 'blank') {
1719
+ fs.writeFileSync(path.join(targetDir, 'public', 'index.tsx'), templates.blankIndex(config));
1720
+ success('Created public/index.tsx');
1721
+ } else {
1722
+ // Contact, SaaS, API all start with similar base
1723
+ fs.writeFileSync(path.join(targetDir, 'public', 'index.tsx'), templates.publicIndex(config));
1724
+ success('Created public/index.tsx');
1725
+
1726
+ if (config.preset !== 'api') {
1727
+ fs.writeFileSync(path.join(targetDir, 'private', 'dashboard.tsx'), templates.privateDashboard(config));
1728
+ fs.writeFileSync(path.join(targetDir, 'private', 'settings.tsx'), templates.privateSettings(config));
1729
+ success('Created private/dashboard.tsx');
1730
+ success('Created private/settings.tsx');
1731
+ }
1732
+
1733
+ fs.writeFileSync(path.join(targetDir, 'backend', 'api.py'), templates.backendApi(config));
1734
+ success('Created backend/api.py');
1735
+ }
1736
+
1737
+ // Common files
1738
+ fs.writeFileSync(path.join(targetDir, 'promptline.yaml'), templates.config(config));
1739
+ success('Created promptline.yaml');
1740
+
1741
+ fs.writeFileSync(path.join(targetDir, 'README.md'), templates.readme(config));
1742
+ success('Created README.md');
1743
+
1744
+ fs.writeFileSync(path.join(targetDir, '.gitignore'), templates.gitignore());
1745
+ success('Created .gitignore');
1746
+
1747
+ // Dev environment files
1748
+ fs.mkdirSync(path.join(targetDir, 'dev'));
1749
+ success('Created dev/');
1750
+
1751
+ // Collect page names for router
1752
+ const pages = { public: ['index'], private: [] };
1753
+ if (config.preset !== 'blank' && config.preset !== 'api') {
1754
+ pages.private = ['dashboard', 'settings'];
1755
+ }
1756
+
1757
+ fs.writeFileSync(path.join(targetDir, 'package.json'), templates.packageJson(config));
1758
+ success('Created package.json');
1759
+
1760
+ fs.writeFileSync(path.join(targetDir, 'vite.config.js'), templates.viteConfig());
1761
+ success('Created vite.config.js');
1762
+
1763
+ fs.writeFileSync(path.join(targetDir, 'index.html'), templates.indexHtml(config));
1764
+ success('Created index.html');
1765
+
1766
+ fs.writeFileSync(path.join(targetDir, 'dev', 'sdk-mock.jsx'), templates.sdkMock(config));
1767
+ success('Created dev/sdk-mock.jsx');
1768
+
1769
+ fs.writeFileSync(path.join(targetDir, 'dev', 'main.jsx'), templates.mainJsx(config, pages));
1770
+ success('Created dev/main.jsx');
1771
+
1772
+ fs.writeFileSync(path.join(targetDir, 'dev', 'dev-admin.jsx'), templates.devAdmin());
1773
+ success('Created dev/dev-admin.jsx');
1774
+
1775
+ // Success message
1776
+ const dirName = path.basename(targetDir);
1777
+ console.log(`
1778
+ ${c.green}╔═══════════════════════════════════════════════════════════╗
1779
+ ║ ║
1780
+ ║ ${c.bold}Success!${c.reset}${c.green} Created ${dirName} ${c.green}║
1781
+ ║ ║
1782
+ ╚═══════════════════════════════════════════════════════════════╝${c.reset}
1783
+
1784
+ ${c.bold}Next steps:${c.reset}
1785
+
1786
+ ${c.cyan}cd ${targetDir}${c.reset}
1787
+ ${c.cyan}npm install${c.reset}
1788
+ ${c.cyan}npm run dev${c.reset}
1789
+
1790
+ This starts a local dev server at ${c.green}http://localhost:5173${c.reset}
1791
+
1792
+ ${c.bold}Configure AI for testing:${c.reset}
1793
+
1794
+ Open ${c.yellow}http://localhost:5173/_dev${c.reset} to configure real AI endpoints
1795
+
1796
+ ${c.bold}Edit your files:${c.reset}
1797
+
1798
+ ${c.yellow}public/${c.reset} Public pages (no auth)
1799
+ ${c.yellow}private/${c.reset} Protected pages (auth required)
1800
+ ${c.yellow}promptline.yaml${c.reset} Package configuration
1801
+
1802
+ ${c.bold}Deploy:${c.reset}
1803
+
1804
+ 1. Create ZIP: ${c.cyan}zip -r ${dirName}.zip ${dirName} -x "node_modules/*" -x ".git/*"${c.reset}
1805
+ 2. Upload to ${c.dim}https://app.promptlineops.com/apps/creator${c.reset}
1806
+
1807
+ ${c.bold}Documentation:${c.reset} ${c.cyan}https://docs.promptlineops.com${c.reset}
1808
+ `);
1809
+
1810
+ } catch (err) {
1811
+ error(`Failed to create package: ${err.message}`);
1812
+ // Cleanup on failure
1813
+ if (fs.existsSync(targetDir)) {
1814
+ fs.rmSync(targetDir, { recursive: true });
1815
+ }
1816
+ process.exit(1);
1817
+ }
1818
+ }
1819
+
1820
+ // Parse arguments
1821
+ function parseArgs() {
1822
+ const args = process.argv.slice(2);
1823
+ const options = {
1824
+ name: null,
1825
+ preset: null,
1826
+ yes: false,
1827
+ help: false,
1828
+ version: false,
1829
+ };
1830
+
1831
+ for (let i = 0; i < args.length; i++) {
1832
+ const arg = args[i];
1833
+
1834
+ if (arg === '--help' || arg === '-h') {
1835
+ options.help = true;
1836
+ } else if (arg === '--version' || arg === '-v') {
1837
+ options.version = true;
1838
+ } else if (arg === '--yes' || arg === '-y') {
1839
+ options.yes = true;
1840
+ } else if (arg === '--preset' || arg === '-p') {
1841
+ options.preset = args[++i];
1842
+ } else if (!arg.startsWith('-') && !options.name) {
1843
+ options.name = arg;
1844
+ }
1845
+ }
1846
+
1847
+ return options;
1848
+ }
1849
+
1850
+ function showHelp() {
1851
+ console.log(`
1852
+ ${c.bold}create-promptline-app${c.reset} - Create PromptLine applications
1853
+
1854
+ ${c.bold}Usage:${c.reset}
1855
+ npx create-promptline-app <app-name> [options]
1856
+
1857
+ ${c.bold}Options:${c.reset}
1858
+ -p, --preset <preset> Template preset (contact, saas, api, blank)
1859
+ -y, --yes Skip prompts, use defaults
1860
+ -h, --help Show this help
1861
+ -v, --version Show version
1862
+
1863
+ ${c.bold}Examples:${c.reset}
1864
+ npx create-promptline-app my-app
1865
+ npx create-promptline-app my-api --preset api
1866
+ npx create-promptline-app my-app -y
1867
+
1868
+ ${c.bold}Presets:${c.reset}
1869
+ contact Landing page + contact form + dashboard (default)
1870
+ saas Full SaaS with auth, dashboard, billing
1871
+ api Backend-focused, minimal frontend
1872
+ blank Empty template
1873
+
1874
+ ${c.bold}Documentation:${c.reset}
1875
+ https://docs.promptlineops.com/packages
1876
+ `);
1877
+ }
1878
+
1879
+ // Main
1880
+ async function main() {
1881
+ const options = parseArgs();
1882
+
1883
+ if (options.help) {
1884
+ showHelp();
1885
+ process.exit(0);
1886
+ }
1887
+
1888
+ if (options.version) {
1889
+ const pkgPath = path.join(__dirname, '..', 'package.json');
1890
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
1891
+ console.log(pkg.version);
1892
+ process.exit(0);
1893
+ }
1894
+
1895
+ if (!options.name) {
1896
+ printLogo();
1897
+ error('Please specify a project name:');
1898
+ console.log(`\n ${c.cyan}npx create-promptline-app${c.reset} ${c.green}<app-name>${c.reset}\n`);
1899
+ console.log('For example:');
1900
+ console.log(` ${c.cyan}npx create-promptline-app${c.reset} ${c.green}my-restaurant-app${c.reset}\n`);
1901
+ console.log(`Run ${c.cyan}npx create-promptline-app --help${c.reset} for more options.`);
1902
+ process.exit(1);
1903
+ }
1904
+
1905
+ await createPackage(options.name, options);
1906
+ }
1907
+
1908
+ main().catch((err) => {
1909
+ error(err.message);
1910
+ process.exit(1);
1911
+ });