@creatorem/cli 0.0.1 → 1.0.4

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 (39) hide show
  1. package/package.json +26 -5
  2. package/src/cli.tsx +141 -0
  3. package/src/commands/create-dashboard.tsx +455 -0
  4. package/src/commands/create-mobile.tsx +555 -0
  5. package/src/commands/create.tsx +1119 -0
  6. package/src/commands/generate-migration.mjs +17 -66
  7. package/src/commands/generate-migration.tsx +46 -0
  8. package/src/commands/generate-schemas.mjs +2 -2
  9. package/src/commands/generate-schemas.tsx +36 -0
  10. package/src/dashboard-features/ai/index.ts +102 -0
  11. package/src/dashboard-features/analytics/index.ts +31 -0
  12. package/src/dashboard-features/billing/index.ts +349 -0
  13. package/src/dashboard-features/content-type/index.ts +64 -0
  14. package/src/dashboard-features/email-templates/index.ts +17 -0
  15. package/src/dashboard-features/emailer/index.ts +27 -0
  16. package/src/dashboard-features/index.ts +28 -0
  17. package/src/dashboard-features/keybindings/index.ts +52 -0
  18. package/src/dashboard-features/manager.ts +349 -0
  19. package/src/dashboard-features/monitoring/index.ts +16 -0
  20. package/src/dashboard-features/notification/index.ts +40 -0
  21. package/src/dashboard-features/onboarding/index.ts +65 -0
  22. package/src/dashboard-features/organization/index.ts +38 -0
  23. package/src/dashboard-features/types.ts +41 -0
  24. package/src/mobile-features/index.ts +12 -0
  25. package/src/mobile-features/manager.ts +1 -0
  26. package/src/mobile-features/notification/index.ts +41 -0
  27. package/src/mobile-features/onboarding/index.ts +35 -0
  28. package/src/mobile-features/organization/index.ts +38 -0
  29. package/src/mobile-features/types.ts +1 -0
  30. package/src/shims/signal-exit.js +9 -0
  31. package/src/ui/app.tsx +68 -0
  32. package/src/ui/multi-select.tsx +106 -0
  33. package/src/utils/ast.ts +422 -0
  34. package/src/utils/env-template.ts +635 -0
  35. package/tests/test-cli-features.sh +81 -0
  36. package/tests/test-cli-mobile.sh +65 -0
  37. package/tsconfig.json +15 -0
  38. package/tsup.config.ts +21 -0
  39. package/bin/cli.mjs +0 -40
@@ -0,0 +1,1119 @@
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Box, Text, useApp, useInput } from 'ink';
3
+ import { MultiSelect } from '../ui/multi-select.js';
4
+ import Spinner from 'ink-spinner';
5
+ import Gradient from 'ink-gradient';
6
+ import TextInput from 'ink-text-input';
7
+ import fs from 'fs-extra';
8
+ import path from 'path';
9
+ import os from 'os';
10
+ import { execa } from 'execa';
11
+ import { features as dashboardFeatures } from '../dashboard-features/index.js';
12
+ import { mobileFeatures } from '../mobile-features/index.js';
13
+ import type { FeatureRemover } from '../dashboard-features/types.js';
14
+ import { processFeature, processFeatureRepo, writeFeatureSelectionManifest } from '../dashboard-features/manager.js';
15
+ import { generateDashboardEnvTemplate } from '../utils/env-template.js';
16
+ import { updateMobileI18nConfig, collectFeatureNamespaces } from './create-mobile.js';
17
+ import { updateDashboardI18nNamespaces } from './create-dashboard.js';
18
+
19
+ const PRO_REPO = 'https://github.com/creatorem/creatorem-saas-kit';
20
+ const OSS_REPO = 'https://github.com/creatorem/creatorem-saas-kit-oss';
21
+
22
+ const detectPackageManager = (root: string): string => {
23
+ if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
24
+ if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
25
+ if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock'))) return 'bun';
26
+ return 'npm';
27
+ };
28
+
29
+ const checkProAccess = async (): Promise<boolean> => {
30
+ try {
31
+ await execa('git', ['ls-remote', '--heads', PRO_REPO], { stdio: 'pipe', timeout: 10000 });
32
+ return true;
33
+ } catch {
34
+ return false;
35
+ }
36
+ };
37
+
38
+ /** Try to resolve the GitHub username via the GH CLI, fall back to git config. */
39
+ const getGitHubUsername = async (): Promise<string> => {
40
+ try {
41
+ const r = await execa('gh', ['api', 'user', '--jq', '.login'], { stdio: 'pipe', timeout: 5000 });
42
+ const u = r.stdout.trim();
43
+ if (u) return u;
44
+ } catch { /* fall through */ }
45
+ try {
46
+ const r = await execa('git', ['config', 'user.name'], { stdio: 'pipe', timeout: 3000 });
47
+ const u = r.stdout.trim();
48
+ if (u) return u;
49
+ } catch { /* fall through */ }
50
+ return '';
51
+ };
52
+
53
+ /** Convert any string to a valid kebab-case folder name. */
54
+ const toKebabCase = (input: string): string =>
55
+ input
56
+ .trim()
57
+ .toLowerCase()
58
+ .replace(/[^a-z0-9]+/g, '-') // replace non-alphanumeric runs with a dash
59
+ .replace(/^-+|-+$/g, ''); // strip leading/trailing dashes
60
+
61
+ // ── Language-processing helpers ───────────────────────────────────────────────
62
+
63
+ /** Walk `rootDir` and return every directory named `locales` (non-recursive past a match). */
64
+ const findLocalesDirs = async (rootDir: string): Promise<string[]> => {
65
+ const result: string[] = [];
66
+ const SKIP = new Set(['node_modules', '.git', '.next', '.expo', '.turbo', 'dist', '.cache']);
67
+ const walk = async (dir: string) => {
68
+ let entries: Awaited<ReturnType<typeof fs.readdir>>;
69
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
70
+ catch { return; }
71
+ for (const entry of entries) {
72
+ if (!entry.isDirectory()) continue;
73
+ if (SKIP.has(entry.name)) continue;
74
+ const fullPath = path.join(dir, entry.name);
75
+ if (entry.name === 'locales') {
76
+ result.push(fullPath);
77
+ } else {
78
+ await walk(fullPath);
79
+ }
80
+ }
81
+ };
82
+ await walk(rootDir);
83
+ return result;
84
+ };
85
+
86
+ /** Recursively replace every string value in a JSON object with an empty string. */
87
+ const emptyJsonValues = (obj: unknown): unknown => {
88
+ if (typeof obj === 'string') return '';
89
+ if (Array.isArray(obj)) return (obj as unknown[]).map(emptyJsonValues);
90
+ if (obj !== null && typeof obj === 'object') {
91
+ const result: Record<string, unknown> = {};
92
+ for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
93
+ result[k] = emptyJsonValues(v);
94
+ }
95
+ return result;
96
+ }
97
+ return obj;
98
+ };
99
+
100
+ /**
101
+ * For every `locales` directory found under `rootDir`:
102
+ * - Removes sub-dirs for languages not in `selectedLanguages` (never removes 'en').
103
+ * - Creates empty-value copies of the `en` locale for any new languages.
104
+ */
105
+ const processLanguages = async (rootDir: string, selectedLanguages: string[]): Promise<void> => {
106
+ const localesDirs = await findLocalesDirs(rootDir);
107
+ for (const localesDir of localesDirs) {
108
+ let entries: Awaited<ReturnType<typeof fs.readdir>>;
109
+ try { entries = await fs.readdir(localesDir, { withFileTypes: true }); }
110
+ catch { continue; }
111
+ const existingLangDirs = entries.filter(e => e.isDirectory()).map(e => e.name);
112
+
113
+ // Remove unselected languages (never remove 'en')
114
+ for (const lang of existingLangDirs) {
115
+ if (lang === 'en') continue;
116
+ if (!selectedLanguages.includes(lang)) {
117
+ await fs.remove(path.join(localesDir, lang));
118
+ }
119
+ }
120
+
121
+ // Create new language dirs from 'en' with empty string values
122
+ const enDir = path.join(localesDir, 'en');
123
+ if (!fs.existsSync(enDir)) continue;
124
+
125
+ for (const lang of selectedLanguages) {
126
+ if (lang === 'en') continue;
127
+ if (existingLangDirs.includes(lang)) continue; // already present
128
+
129
+ const newLangDir = path.join(localesDir, lang);
130
+ await fs.ensureDir(newLangDir);
131
+ const enFiles = (await fs.readdir(enDir)).filter((f: string) => f.endsWith('.json'));
132
+ for (const file of enFiles) {
133
+ const enContent = await fs.readJson(path.join(enDir, file));
134
+ await fs.writeJson(path.join(newLangDir, file), emptyJsonValues(enContent), { spaces: 4 });
135
+ }
136
+ }
137
+ }
138
+ };
139
+
140
+ /** Overwrites `packages/shared/src/config/defined-languages.ts` with the given language list. */
141
+ const updateDefinedLanguages = async (rootDir: string, selectedLanguages: string[]): Promise<void> => {
142
+ const filePath = path.join(rootDir, 'packages', 'shared', 'src', 'config', 'defined-languages.ts');
143
+ if (!fs.existsSync(filePath)) return;
144
+ const langsStr = selectedLanguages.map(l => `'${l}'`).join(', ');
145
+ await fs.writeFile(
146
+ filePath,
147
+ `export const DEFAULT_LANG = 'en';\nexport const SUPPORTED_LANGS = [${langsStr}];\n`,
148
+ 'utf-8',
149
+ );
150
+ };
151
+
152
+ const APP_DIR_NAMES: Record<string, string[]> = {
153
+ marketing: ['marketing', 'web', 'www'],
154
+ dashboard: ['dashboard'],
155
+ mobile: ['mobile'],
156
+ api: ['api'],
157
+ };
158
+
159
+ // ── Step type ─────────────────────────────────────────────────────────────────
160
+ //
161
+ // Each app is asked individually so the user immediately sees follow-up
162
+ // questions (e.g. feature selection) right after confirming that app.
163
+ //
164
+ // Flow:
165
+ // name → git-setup → app-marketing → app-dashboard
166
+ // → [dashboard-features] → app-mobile → [mobile-features]
167
+ // → app-example → [example-apps] → languages → [languages-custom] → processing → done
168
+
169
+ type Step =
170
+ | 'intro'
171
+ | 'name'
172
+ | 'git-setup'
173
+ | 'app-marketing'
174
+ | 'app-dashboard'
175
+ | 'dashboard-features'
176
+ | 'app-mobile'
177
+ | 'mobile-features'
178
+ | 'app-example'
179
+ | 'example-apps'
180
+ | 'example-apps-loading'
181
+ | 'languages'
182
+ | 'languages-custom'
183
+ | 'processing'
184
+ | 'done'
185
+ | 'error';
186
+
187
+ // ── Welcome banner ────────────────────────────────────────────────────────────
188
+
189
+ interface WelcomeBannerProps {
190
+ username: string;
191
+ isPro: boolean;
192
+ }
193
+
194
+ const WelcomeBanner: React.FC<WelcomeBannerProps> = ({ username, isPro }) => {
195
+ const repoName = isPro ? 'creatorem-saas-kit' : 'creatorem-saas-kit-oss';
196
+ return (
197
+ <Box flexDirection="column" marginBottom={1}>
198
+ <Text> </Text>
199
+ <Text>Hi {username || 'there'}!</Text>
200
+ <Text>
201
+ {isPro
202
+ ? <Text bold color="green">Premium user detected.</Text>
203
+ : <Text bold color="blue">OSS usage.</Text>
204
+ }
205
+ {' We gonna use the '}
206
+ <Text bold color="green">{repoName}</Text>
207
+ {' repository.'}
208
+ </Text>
209
+ </Box>
210
+ );
211
+ };
212
+
213
+ // ── Persistent choices display ────────────────────────────────────────────────
214
+
215
+ interface HistoryEntry {
216
+ label: string;
217
+ value: string;
218
+ valueColor?: string;
219
+ }
220
+
221
+ /** Renders all confirmed choices so far above the current prompt. */
222
+ const ChoicesDisplay: React.FC<{ entries: HistoryEntry[] }> = ({ entries }) => {
223
+ if (entries.length === 0) return null;
224
+ return (
225
+ <Box flexDirection="column" marginBottom={1}>
226
+ {entries.map((entry, i) => (
227
+ <Box key={i}>
228
+ <Box minWidth={22}>
229
+ <Text color="gray">{entry.label}</Text>
230
+ </Box>
231
+ <Text color={entry.valueColor ?? 'white'}>{entry.value}</Text>
232
+ </Box>
233
+ ))}
234
+ </Box>
235
+ );
236
+ };
237
+
238
+ // ── Confirm sub-component ─────────────────────────────────────────────────────
239
+
240
+ interface ConfirmProps {
241
+ question: string;
242
+ onConfirm: (yes: boolean) => void;
243
+ }
244
+
245
+ const Confirm: React.FC<ConfirmProps> = ({ question, onConfirm }) => {
246
+ const [focused, setFocused] = useState<0 | 1>(0); // 0 = Yes, 1 = No
247
+
248
+ useInput((_input, key) => {
249
+ if (key.leftArrow || key.upArrow) setFocused(0);
250
+ if (key.rightArrow || key.downArrow) setFocused(1);
251
+ if (key.return) onConfirm(focused === 0);
252
+ });
253
+
254
+ return (
255
+ <Box flexDirection="column">
256
+ <Text bold>{question}</Text>
257
+ <Box marginTop={1}>
258
+ <Box marginRight={4}>
259
+ <Text color={focused === 0 ? 'cyan' : undefined}>
260
+ {focused === 0 ? '› ' : ' '}Yes
261
+ </Text>
262
+ </Box>
263
+ <Text color={focused === 1 ? 'cyan' : undefined}>
264
+ {focused === 1 ? '› ' : ' '}No
265
+ </Text>
266
+ </Box>
267
+ <Box marginTop={1}>
268
+ <Text color="gray" dimColor>Arrow keys · Enter to confirm</Text>
269
+ </Box>
270
+ </Box>
271
+ );
272
+ };
273
+
274
+ // ── Helper ────────────────────────────────────────────────────────────────────
275
+
276
+ /** Compact summary of selected feature keys for the history display. */
277
+ function summariseFeatures(
278
+ selectedKeys: string[],
279
+ allFeatures: Array<{ key: string }>,
280
+ ): string {
281
+ if (selectedKeys.length === 0) return 'Core only';
282
+ if (selectedKeys.length === allFeatures.length) return `All (${allFeatures.length})`;
283
+ const MAX = 4;
284
+ const keys = allFeatures.filter(f => selectedKeys.includes(f.key)).map(f => f.key);
285
+ return keys.length <= MAX
286
+ ? keys.join(', ')
287
+ : `${keys.slice(0, MAX).join(', ')} +${keys.length - MAX} more`;
288
+ }
289
+
290
+ // ── Language selection items ──────────────────────────────────────────────────
291
+ // 'en' is always included and is shown as a static note, not in this list.
292
+ const OPTIONAL_LANGUAGE_ITEMS = [
293
+ {
294
+ value: 'fr',
295
+ label: 'French (fr)',
296
+ title: 'French (fr)',
297
+ description: 'French language support',
298
+ },
299
+ ];
300
+
301
+ // ── Main component ────────────────────────────────────────────────────────────
302
+
303
+ interface CreateProps {
304
+ args: string[];
305
+ flags?: Record<string, any>;
306
+ }
307
+
308
+ interface ExampleAppItem {
309
+ value: string;
310
+ title: string;
311
+ description?: string;
312
+ }
313
+
314
+ interface MonorepoManifest {
315
+ monorepoName: string;
316
+ selectedApps: string[];
317
+ selectedExampleApps: string[];
318
+ availableFeatures: string[];
319
+ featuresByApp: {
320
+ dashboard: string[];
321
+ mobile: string[];
322
+ };
323
+ cliOptions: {
324
+ gitInit: boolean;
325
+ template: 'pro' | 'oss';
326
+ };
327
+ }
328
+
329
+ const writeMonorepoManifest = async (projectRoot: string, manifest: MonorepoManifest) => {
330
+ const metadataDir = path.join(projectRoot, '.creatorem');
331
+ await fs.ensureDir(metadataDir);
332
+ await fs.writeJson(path.join(metadataDir, 'monorepo.json'), manifest, { spaces: 2 });
333
+ };
334
+
335
+ export const Create: React.FC<CreateProps> = ({ args, flags = {} }) => {
336
+ const { exit } = useApp();
337
+
338
+ const initialName = args[0] ? toKebabCase(args[0]) : '';
339
+
340
+ // ── Core state ────────────────────────────────────────────────────────────
341
+ const [projectName, setProjectName] = useState(initialName);
342
+ const [nameError, setNameError] = useState('');
343
+ const [step, setStep] = useState<Step>('intro');
344
+
345
+ // Collected choices
346
+ const [initGit, setInitGit] = useState(false);
347
+ const [includeMarketing, setIncludeMarketing] = useState(false);
348
+ const [includeDashboard, setIncludeDashboard] = useState(false);
349
+ const [includeMobile, setIncludeMobile] = useState(false);
350
+ const [selectedDashboardFeatures, setSelectedDashboardFeatures] = useState<string[]>([]);
351
+ const [selectedMobileFeatures, setSelectedMobileFeatures] = useState<string[]>([]);
352
+ const [availableExampleApps, setAvailableExampleApps] = useState<ExampleAppItem[]>([]);
353
+
354
+ // Language selection
355
+ const [pendingApps, setPendingApps] = useState<string[]>([]);
356
+ const [pendingExampleApps, setPendingExampleApps] = useState<string[]>([]);
357
+ const [selectedOptionalLangs, setSelectedOptionalLangs] = useState<string[]>(['fr']);
358
+ const [customLangInput, setCustomLangInput] = useState('');
359
+ // Guards to prevent double-submit (terminal sends \r\n as two events)
360
+ const processingStartedRef = useRef(false);
361
+
362
+ // History shown above every prompt — pre-seeded if name came from CLI args
363
+ const [history, setHistory] = useState<HistoryEntry[]>(
364
+ initialName ? [{ label: 'Project', value: initialName, valueColor: 'cyan' }] : [],
365
+ );
366
+
367
+ // Processing
368
+ const [currentAction, setCurrentAction] = useState('');
369
+ const [error, setError] = useState('');
370
+ const [usedProRepo, setUsedProRepo] = useState<boolean | null>(null);
371
+ const [githubUsername, setGithubUsername] = useState('');
372
+
373
+ // ── Intro check — runs once on mount ─────────────────────────────────────
374
+ useEffect(() => {
375
+ Promise.all([checkProAccess(), getGitHubUsername()])
376
+ .then(([hasPro, username]) => {
377
+ setUsedProRepo(hasPro);
378
+ setGithubUsername(username);
379
+ setStep(initialName ? 'git-setup' : 'name');
380
+ })
381
+ .catch(() => {
382
+ setUsedProRepo(false);
383
+ setStep(initialName ? 'git-setup' : 'name');
384
+ });
385
+ }, []);
386
+
387
+ // ── Custom language text input — handled at component level to avoid ink-text-input key-bleed ──
388
+ // isActive: only processes input when the languages-custom step is shown.
389
+ // The step is always transitioned into via setImmediate (in handleLanguagesSubmit), so by the
390
+ // time any keypress reaches here the previous step's stdin events are fully drained.
391
+ useInput((input, key) => {
392
+ if (key.return) {
393
+ if (processingStartedRef.current) return;
394
+ processingStartedRef.current = true;
395
+ handleCustomLangsSubmit(customLangInput);
396
+ return;
397
+ }
398
+ if (key.backspace || key.delete) {
399
+ setCustomLangInput(prev => prev.slice(0, -1));
400
+ return;
401
+ }
402
+ if (key.escape) {
403
+ return;
404
+ }
405
+ if (input && !key.ctrl && !key.meta && !key.tab) {
406
+ setCustomLangInput(prev => prev + input);
407
+ }
408
+ }, { isActive: step === 'languages-custom' });
409
+
410
+ const getFeatureTitle = (feature: FeatureRemover) => feature.cliUI.title;
411
+
412
+ const dashboardFeatureItems = dashboardFeatures.map(feature => ({
413
+ label: feature.cliUI.title,
414
+ value: feature.key,
415
+ title: feature.cliUI.title,
416
+ description: feature.cliUI.description,
417
+ features: feature.cliUI.features,
418
+ }));
419
+ const mobileFeatureItems = mobileFeatures.map(feature => ({
420
+ label: feature.cliUI.title,
421
+ value: feature.key,
422
+ title: feature.cliUI.title,
423
+ description: feature.cliUI.description,
424
+ features: feature.cliUI.features,
425
+ }));
426
+
427
+ const addHistory = (label: string, value: string, valueColor?: string) =>
428
+ setHistory(prev => [...prev, { label, value, valueColor }]);
429
+
430
+ const loadAvailableExampleApps = async (): Promise<ExampleAppItem[]> => {
431
+ const tmpDir = path.join(os.tmpdir(), `creatorem-examples-${Date.now()}`);
432
+
433
+ try {
434
+ // Reuse the pro status already resolved during the intro check
435
+ const hasPro = usedProRepo ?? false;
436
+ const repoUrl = hasPro ? PRO_REPO : OSS_REPO;
437
+
438
+ setCurrentAction('Loading available example apps...');
439
+ await execa('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe' });
440
+
441
+ const examplesDir = path.join(tmpDir, 'examples');
442
+ if (!fs.existsSync(examplesDir)) return [];
443
+
444
+ const entries = await fs.readdir(examplesDir, { withFileTypes: true });
445
+ const items: ExampleAppItem[] = [];
446
+
447
+ for (const entry of entries) {
448
+ if (!entry.isDirectory()) continue;
449
+
450
+ const dirName = entry.name;
451
+ const packageJsonPath = path.join(examplesDir, dirName, 'package.json');
452
+ if (!fs.existsSync(packageJsonPath)) continue;
453
+
454
+ let pkg: Record<string, unknown> = {};
455
+ try {
456
+ pkg = await fs.readJson(packageJsonPath);
457
+ } catch {
458
+ continue;
459
+ }
460
+
461
+ const title =
462
+ (typeof pkg.title === 'string' && pkg.title.trim()) ||
463
+ (typeof pkg.displayName === 'string' && pkg.displayName.trim()) ||
464
+ (typeof pkg.name === 'string' && pkg.name.trim()) ||
465
+ dirName;
466
+ const description =
467
+ typeof pkg.description === 'string' && pkg.description.trim()
468
+ ? pkg.description.trim()
469
+ : undefined;
470
+
471
+ items.push({
472
+ value: dirName,
473
+ title,
474
+ description,
475
+ });
476
+ }
477
+
478
+ return items.sort((a, b) => a.title.localeCompare(b.title));
479
+ } finally {
480
+ if (fs.existsSync(tmpDir)) {
481
+ await fs.remove(tmpDir).catch(() => { });
482
+ }
483
+ }
484
+ };
485
+
486
+ const summariseExampleApps = (selectedDirs: string[]): string => {
487
+ if (selectedDirs.length === 0) return 'None';
488
+ const selectedTitles = availableExampleApps
489
+ .filter(item => selectedDirs.includes(item.value))
490
+ .map(item => item.title);
491
+ if (selectedTitles.length === 0) return 'None';
492
+ if (selectedTitles.length <= 3) return selectedTitles.join(', ');
493
+ return `${selectedTitles.slice(0, 3).join(', ')} +${selectedTitles.length - 3} more`;
494
+ };
495
+
496
+ // ── Async scaffolding work ────────────────────────────────────────────────
497
+
498
+ const runCreate = async (
499
+ name: string,
500
+ apps: string[],
501
+ gitInit: boolean,
502
+ dashFeatureKeys: string[],
503
+ mobFeatureKeys: string[],
504
+ exampleAppDirs: string[],
505
+ selectedLanguages: string[],
506
+ ) => {
507
+ const targetDir = path.resolve(process.cwd(), name);
508
+ const tmpDir = path.join(os.tmpdir(), `creatorem-create-${Date.now()}`);
509
+ const dashboardSelected = apps.includes('dashboard');
510
+ const mobileSelected = apps.includes('mobile');
511
+ const shouldIncludeApi = mobileSelected && !dashboardSelected;
512
+ const appsToProvision = Array.from(
513
+ new Set(shouldIncludeApi ? [...apps, 'api'] : apps),
514
+ );
515
+ const selectedDashboardFeatureKeys = dashboardFeatures
516
+ .filter(feature => dashFeatureKeys.includes(feature.key))
517
+ .map(feature => feature.key);
518
+ const selectedMobileFeatureKeys = mobileFeatures
519
+ .filter(feature => mobFeatureKeys.includes(feature.key))
520
+ .map(feature => feature.key);
521
+ const selectedAppKeys = Array.from(
522
+ new Set(appsToProvision.map(app => app.trim()).filter(Boolean)),
523
+ );
524
+ const selectedExampleAppKeys = Array.from(
525
+ new Set(exampleAppDirs.map(example => example.trim()).filter(Boolean)),
526
+ );
527
+
528
+ try {
529
+ // 1. Repo URL — pro status already resolved during intro check ────
530
+ const hasPro = usedProRepo ?? false;
531
+ const repoUrl = hasPro ? PRO_REPO : OSS_REPO;
532
+
533
+ // 2. Clone ─────────────────────────────────────────────────────────
534
+ setCurrentAction('Cloning template repository (this may take a moment)...');
535
+ await execa('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe' });
536
+ // Remove .git — we'll re-init below if requested
537
+ await fs.remove(path.join(tmpDir, '.git'));
538
+
539
+ // 3. Remove unwanted apps ──────────────────────────────────────────
540
+ setCurrentAction('Removing unwanted apps...');
541
+ const appsDir = path.join(tmpDir, 'apps');
542
+ if (fs.existsSync(appsDir)) {
543
+ const appEntries = await fs.readdir(appsDir);
544
+ for (const entry of appEntries) {
545
+ const shouldKeep = appsToProvision.some(app => (APP_DIR_NAMES[app] ?? []).includes(entry));
546
+ if (!shouldKeep) await fs.remove(path.join(appsDir, entry));
547
+ }
548
+ }
549
+
550
+ const examplesDir = path.join(tmpDir, 'examples');
551
+ if (fs.existsSync(examplesDir)) {
552
+ if (!appsToProvision.includes('example') || exampleAppDirs.length === 0) {
553
+ await fs.remove(examplesDir);
554
+ } else {
555
+ const selected = new Set(exampleAppDirs);
556
+ const exampleEntries = await fs.readdir(examplesDir, { withFileTypes: true });
557
+ for (const entry of exampleEntries) {
558
+ if (!entry.isDirectory()) continue;
559
+ if (!selected.has(entry.name)) {
560
+ await fs.remove(path.join(examplesDir, entry.name));
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ const dashboardIncluded = appsToProvision.includes('dashboard');
567
+ const mobileIncluded = appsToProvision.includes('mobile');
568
+ const apiIncluded = appsToProvision.includes('api');
569
+ const apiOnlyBackend = apiIncluded && !dashboardIncluded;
570
+ const exampleIncluded = appsToProvision.includes('example');
571
+
572
+ // 4. App-level feature removals ────────────────────────────────────
573
+ // Applied per-app independently — each app only cares about its
574
+ // own feature selection regardless of what the other app chose.
575
+
576
+ if (dashboardIncluded) {
577
+ const dashboardAppDir = path.join(tmpDir, 'apps', 'dashboard');
578
+ if (fs.existsSync(dashboardAppDir)) {
579
+ const toRemove = dashboardFeatures.filter(f => !selectedDashboardFeatureKeys.includes(f.key));
580
+ for (const feature of toRemove) {
581
+ setCurrentAction(`[dashboard] Removing: ${getFeatureTitle(feature)}...`);
582
+ await processFeature(feature, dashboardAppDir);
583
+ }
584
+ setCurrentAction('[dashboard] Generating .env.template...');
585
+ await generateDashboardEnvTemplate(dashboardAppDir, tmpDir, {
586
+ createEnvFile: true,
587
+ fallbackMonoRoot: process.cwd(),
588
+ });
589
+ setCurrentAction('[dashboard] Saving selected features metadata...');
590
+ await writeFeatureSelectionManifest(dashboardAppDir, 'dashboard', selectedDashboardFeatureKeys);
591
+
592
+ const dashRemovedNs = collectFeatureNamespaces(toRemove);
593
+ if (dashRemovedNs.length > 0) {
594
+ setCurrentAction('[dashboard] Updating i18n namespaces...');
595
+ await updateDashboardI18nNamespaces(dashboardAppDir, dashRemovedNs);
596
+ }
597
+ }
598
+ }
599
+
600
+ if (mobileIncluded) {
601
+ const mobileAppDir = path.join(tmpDir, 'apps', 'mobile');
602
+ if (fs.existsSync(mobileAppDir)) {
603
+ const toRemove = mobileFeatures.filter(f => !selectedMobileFeatureKeys.includes(f.key));
604
+ for (const feature of toRemove) {
605
+ setCurrentAction(`[mobile] Removing: ${getFeatureTitle(feature)}...`);
606
+ await processFeature(feature, mobileAppDir, 'mobile');
607
+ }
608
+ setCurrentAction('[mobile] Generating .env.template...');
609
+ await generateDashboardEnvTemplate(mobileAppDir, tmpDir, {
610
+ createEnvFile: true,
611
+ fallbackMonoRoot: process.cwd(),
612
+ });
613
+ setCurrentAction('[mobile] Saving selected features metadata...');
614
+ await writeFeatureSelectionManifest(mobileAppDir, 'mobile', selectedMobileFeatureKeys);
615
+ }
616
+ }
617
+
618
+ if (apiIncluded) {
619
+ const apiAppDir = path.join(tmpDir, 'apps', 'api');
620
+ if (!fs.existsSync(apiAppDir)) {
621
+ throw new Error('Template repo does not contain apps/api — please report this issue.');
622
+ }
623
+ const trpcRoutePath = path.join(apiAppDir, 'app', 'api', 'trpc', '[trpc]', 'route.ts');
624
+ if (!fs.existsSync(trpcRoutePath)) {
625
+ throw new Error('apps/api is missing app/api/trpc/[trpc]/route.ts in template repo.');
626
+ }
627
+ if (apiOnlyBackend) {
628
+ const toRemoveForApi = mobileFeatures.filter(
629
+ feature => !selectedMobileFeatureKeys.includes(feature.key),
630
+ );
631
+ for (const feature of toRemoveForApi) {
632
+ setCurrentAction(`[api] Removing: ${getFeatureTitle(feature)}...`);
633
+ await processFeature(feature, apiAppDir, 'dashboard');
634
+ }
635
+ }
636
+ setCurrentAction('[api] Generating .env.template...');
637
+ await generateDashboardEnvTemplate(apiAppDir, tmpDir, {
638
+ createEnvFile: true,
639
+ fallbackMonoRoot: process.cwd(),
640
+ });
641
+ }
642
+
643
+ // 5. Repo-level cleanup ────────────────────────────────────────────
644
+ //
645
+ // - packages/pco-shared is example-specific and is removed when
646
+ // no example app is included.
647
+ // - kit/X is deleted only when X is not needed by ANY selected app.
648
+ // If an example app is included we keep pco-shared and skip
649
+ // feature-driven repo cleanup because examples may depend on
650
+ // arbitrary kit packages.
651
+ //
652
+ // Cross-app safety:
653
+ // dashboard without 'organization' + mobile WITH 'organization'
654
+ // → featureKeysNeeded contains 'organization' → kit/organization stays
655
+
656
+ if (!exampleIncluded) {
657
+ const pcoSharedPackageDir = path.join(tmpDir, 'packages', 'pco-shared');
658
+ if (fs.existsSync(pcoSharedPackageDir)) {
659
+ setCurrentAction('Removing packages/pco-shared...');
660
+ await fs.remove(pcoSharedPackageDir);
661
+ }
662
+
663
+ const toRemoveFromRepo = apiOnlyBackend
664
+ ? mobileFeatures.filter(f => !selectedMobileFeatureKeys.includes(f.key))
665
+ : (() => {
666
+ const featureKeysNeeded = new Set<string>();
667
+ if (dashboardIncluded) selectedDashboardFeatureKeys.forEach(k => featureKeysNeeded.add(k));
668
+ if (mobileIncluded) selectedMobileFeatureKeys.forEach(k => featureKeysNeeded.add(k));
669
+
670
+ // Deduplicate across both feature lists; dashboard takes priority
671
+ // for repo-level config (more complete supabase schema lists etc.)
672
+ const allRemovers = new Map<string, FeatureRemover>();
673
+ for (const f of [...dashboardFeatures, ...mobileFeatures]) {
674
+ if (!allRemovers.has(f.key)) allRemovers.set(f.key, f);
675
+ }
676
+
677
+ return Array.from(allRemovers.values())
678
+ .filter(f => !featureKeysNeeded.has(f.key));
679
+ })();
680
+
681
+ if (toRemoveFromRepo.length > 0) {
682
+ // Uninstall deps first — packages must still exist on disk
683
+ const pm = detectPackageManager(tmpDir);
684
+ const depsToUninstall = [
685
+ ...new Set(toRemoveFromRepo.flatMap(f => f.dependenciesToRemove ?? [])),
686
+ ];
687
+ if (depsToUninstall.length > 0) {
688
+ setCurrentAction(`Uninstalling ${depsToUninstall.length} unused package(s)...`);
689
+ const removeCmd = pm === 'npm' ? 'uninstall' : 'remove';
690
+ const workspaceFlag =
691
+ pm === 'pnpm' ? ['-r'] :
692
+ pm === 'yarn' ? ['-W'] :
693
+ pm === 'bun' ? [] : ['-w'];
694
+ try {
695
+ await execa(pm, [removeCmd, ...workspaceFlag, ...depsToUninstall], {
696
+ cwd: tmpDir, stdio: 'pipe',
697
+ });
698
+ } catch { /* non-fatal */ }
699
+ }
700
+ for (const feature of toRemoveFromRepo) {
701
+ setCurrentAction(`Cleaning kit/${feature.key}...`);
702
+ await processFeatureRepo(feature, tmpDir);
703
+ }
704
+ }
705
+ }
706
+
707
+ // 6. Update root package.json name ────────────────────────────────
708
+ const rootPkgPath = path.join(tmpDir, 'package.json');
709
+ if (fs.existsSync(rootPkgPath)) {
710
+ const pkg = await fs.readJson(rootPkgPath);
711
+ pkg.name = path.basename(name);
712
+ await fs.writeJson(rootPkgPath, pkg, { spaces: 4 });
713
+ }
714
+
715
+ // 7. Language setup ────────────────────────────────────────────────
716
+ setCurrentAction('Configuring language support...');
717
+ await processLanguages(tmpDir, selectedLanguages);
718
+ setCurrentAction('Updating SUPPORTED_LANGS...');
719
+ await updateDefinedLanguages(tmpDir, selectedLanguages);
720
+ if (mobileIncluded) {
721
+ const mobileI18nDir = path.join(tmpDir, 'apps', 'mobile');
722
+ if (fs.existsSync(mobileI18nDir)) {
723
+ setCurrentAction('[mobile] Updating i18n configuration...');
724
+ const removedMobileFeatures = mobileFeatures.filter(
725
+ f => !selectedMobileFeatureKeys.includes(f.key),
726
+ );
727
+ const mobileI18nNsToRemove = collectFeatureNamespaces(removedMobileFeatures);
728
+ await updateMobileI18nConfig(mobileI18nDir, selectedLanguages, mobileI18nNsToRemove);
729
+ }
730
+ }
731
+
732
+ // 8. Save root monorepo metadata ───────────────────────────────────
733
+ setCurrentAction('Writing monorepo metadata...');
734
+ const availableFeatures = Array.from(
735
+ new Set([...selectedDashboardFeatureKeys, ...selectedMobileFeatureKeys]),
736
+ ).sort((a, b) => a.localeCompare(b));
737
+ await writeMonorepoManifest(tmpDir, {
738
+ monorepoName: path.basename(name),
739
+ selectedApps: selectedAppKeys,
740
+ selectedExampleApps: selectedExampleAppKeys,
741
+ availableFeatures,
742
+ featuresByApp: {
743
+ dashboard: selectedDashboardFeatureKeys,
744
+ mobile: selectedMobileFeatureKeys,
745
+ },
746
+ cliOptions: {
747
+ gitInit,
748
+ template: hasPro ? 'pro' : 'oss',
749
+ },
750
+ });
751
+
752
+ // 9. Move to final destination ─────────────────────────────────────
753
+ setCurrentAction(`Creating project at ./${name}...`);
754
+ if (fs.existsSync(targetDir)) await fs.remove(targetDir);
755
+ await fs.move(tmpDir, targetDir);
756
+
757
+ // 10. Git init ─────────────────────────────────────────────────────
758
+ if (gitInit) {
759
+ setCurrentAction('Initializing git repository...');
760
+ await execa('git', ['init'], { cwd: targetDir, stdio: 'pipe' });
761
+ await execa('git', ['add', '.'], { cwd: targetDir, stdio: 'pipe' });
762
+ try {
763
+ await execa(
764
+ 'git',
765
+ ['commit', '-m', 'chore: initial commit (Creatorem SaaS Kit)'],
766
+ { cwd: targetDir, stdio: 'pipe' },
767
+ );
768
+ } catch { /* non-fatal: requires git user config */ }
769
+ }
770
+
771
+ setCurrentAction('Done.');
772
+ } catch (err) {
773
+ if (fs.existsSync(tmpDir)) await fs.remove(tmpDir).catch(() => {});
774
+ throw err;
775
+ }
776
+ };
777
+
778
+ const startProcessing = (
779
+ name: string,
780
+ apps: string[],
781
+ gitInit: boolean,
782
+ dashFeatures: string[],
783
+ mobFeatures: string[],
784
+ exampleApps: string[],
785
+ languages: string[],
786
+ ) => {
787
+ setStep('processing');
788
+ runCreate(name, apps, gitInit, dashFeatures, mobFeatures, exampleApps, languages)
789
+ .then(() => { setStep('done'); setTimeout(() => exit(), 100); })
790
+ .catch((err: any) => { setError(err.message); setStep('error'); exit(err); });
791
+ };
792
+
793
+ /** Store pending app/example selections and navigate to the language step.
794
+ * setImmediate defers the step transition until after all stdin events from the
795
+ * current keypress have been drained, preventing key-bleed into the next step. */
796
+ const goToLanguages = (apps: string[], exampleApps: string[]) => {
797
+ setPendingApps(apps);
798
+ setPendingExampleApps(exampleApps);
799
+ setImmediate(() => setStep('languages'));
800
+ };
801
+
802
+ // ── Step handlers ─────────────────────────────────────────────────────────
803
+
804
+ const handleNameSubmit = (name: string) => {
805
+ const kebab = toKebabCase(name);
806
+ if (!kebab) { setNameError('Project name is required'); return; }
807
+ setProjectName(kebab);
808
+ addHistory('Project', kebab, 'cyan');
809
+ setNameError('');
810
+ setImmediate(() => setStep('git-setup'));
811
+ };
812
+
813
+ const handleGitConfirm = (yes: boolean) => {
814
+ setInitGit(yes);
815
+ addHistory('Git init', yes ? 'Yes' : 'No', yes ? 'green' : 'gray');
816
+ setImmediate(() => setStep('app-marketing'));
817
+ };
818
+
819
+ const handleMarketingConfirm = (yes: boolean) => {
820
+ setIncludeMarketing(yes);
821
+ addHistory('Marketing website', yes ? 'Yes' : 'No', yes ? 'green' : 'gray');
822
+ setImmediate(() => setStep('app-dashboard'));
823
+ };
824
+
825
+ const handleDashboardConfirm = (yes: boolean) => {
826
+ setIncludeDashboard(yes);
827
+ addHistory('Dashboard app', yes ? 'Yes' : 'No', yes ? 'green' : 'gray');
828
+ setImmediate(() => setStep(yes ? 'dashboard-features' : 'app-mobile'));
829
+ };
830
+
831
+ const handleDashboardFeaturesSubmit = (selected: string[]) => {
832
+ setSelectedDashboardFeatures(selected);
833
+ addHistory(' └ features', summariseFeatures(selected, dashboardFeatures), 'cyan');
834
+ setImmediate(() => setStep('app-mobile'));
835
+ };
836
+
837
+ const handleMobileConfirm = (yes: boolean) => {
838
+ setIncludeMobile(yes);
839
+ addHistory('Mobile app', yes ? 'Yes' : 'No', yes ? 'green' : 'gray');
840
+ if (!yes) {
841
+ setSelectedMobileFeatures([]);
842
+ }
843
+ setImmediate(() => setStep(yes ? 'mobile-features' : 'app-example'));
844
+ };
845
+
846
+ const handleMobileFeaturesSubmit = (selected: string[]) => {
847
+ setSelectedMobileFeatures(selected);
848
+ addHistory(' └ features', summariseFeatures(selected, mobileFeatures), 'cyan');
849
+ setImmediate(() => setStep('app-example'));
850
+ };
851
+
852
+ const handleLanguagesSubmit = (selected: string[]) => {
853
+ setSelectedOptionalLangs(selected);
854
+ const allLangs = ['en', ...selected];
855
+ addHistory('Languages', allLangs.join(', '), 'cyan');
856
+ // setImmediate defers the step transition until after all stdin events from this
857
+ // keypress are drained — prevents bleed into the languages-custom useInput handler.
858
+ setImmediate(() => {
859
+ processingStartedRef.current = false; // reset guard for the new step
860
+ setCustomLangInput('');
861
+ setStep('languages-custom');
862
+ });
863
+ };
864
+
865
+ const handleCustomLangsSubmit = (input: string) => {
866
+ const customLangs = input
867
+ .split(',')
868
+ .map((l: string) => l.trim().toLowerCase())
869
+ .filter((l: string) => l.length > 0 && l !== 'en' && !selectedOptionalLangs.includes(l));
870
+ const allLangs = ['en', ...selectedOptionalLangs, ...customLangs];
871
+ if (customLangs.length > 0) {
872
+ addHistory(' + custom langs', customLangs.join(', '), 'cyan');
873
+ }
874
+ startProcessing(
875
+ projectName,
876
+ pendingApps,
877
+ initGit,
878
+ selectedDashboardFeatures,
879
+ selectedMobileFeatures,
880
+ pendingExampleApps,
881
+ allLangs,
882
+ );
883
+ };
884
+
885
+ const handleExampleAppsSubmit = (selected: string[]) => {
886
+ addHistory('Example apps', summariseExampleApps(selected), selected.length > 0 ? 'cyan' : 'gray');
887
+
888
+ const apps: string[] = [];
889
+ if (includeMarketing) apps.push('marketing');
890
+ if (includeDashboard) apps.push('dashboard');
891
+ if (includeMobile) apps.push('mobile');
892
+ if (selected.length > 0) apps.push('example');
893
+
894
+ goToLanguages(apps, selected);
895
+ };
896
+
897
+ // Last step — optionally select one or more example apps
898
+ const handleExampleConfirm = async (yes: boolean) => {
899
+ const buildApps = () => {
900
+ const apps: string[] = [];
901
+ if (includeMarketing) apps.push('marketing');
902
+ if (includeDashboard) apps.push('dashboard');
903
+ if (includeMobile) apps.push('mobile');
904
+ return apps;
905
+ };
906
+
907
+ if (!yes) {
908
+ addHistory('Example apps', 'None', 'gray');
909
+ goToLanguages(buildApps(), []);
910
+ return;
911
+ }
912
+
913
+ setStep('example-apps-loading');
914
+ try {
915
+ const items = await loadAvailableExampleApps();
916
+ setAvailableExampleApps(items);
917
+ if (items.length === 0) {
918
+ addHistory('Example apps', 'None available', 'gray');
919
+ goToLanguages(buildApps(), []);
920
+ return;
921
+ }
922
+ setStep('example-apps');
923
+ } catch (err: any) {
924
+ setError(err.message);
925
+ setStep('error');
926
+ exit(err);
927
+ }
928
+ };
929
+
930
+ // ── Render ────────────────────────────────────────────────────────────────
931
+
932
+ // Shared header: welcome banner (once intro resolves) + accumulated choices
933
+ const renderHeader = () => (
934
+ <>
935
+ {usedProRepo !== null && (
936
+ <WelcomeBanner username={githubUsername} isPro={usedProRepo} />
937
+ )}
938
+ <ChoicesDisplay entries={history} />
939
+ </>
940
+ );
941
+
942
+ if (step === 'intro') {
943
+ return (
944
+ <Box>
945
+ <Spinner type="dots" />
946
+ <Text> Checking access...</Text>
947
+ </Box>
948
+ );
949
+ }
950
+
951
+ if (step === 'error') {
952
+ return <Text color="red">Error: {error}</Text>;
953
+ }
954
+
955
+ if (step === 'done') {
956
+ return (
957
+ <Box flexDirection="column">
958
+ {renderHeader()}
959
+ <Box marginTop={1} flexDirection="column">
960
+ <Gradient name="pastel">
961
+ <Text>Monorepo "{projectName}" created successfully!</Text>
962
+ </Gradient>
963
+ <Box marginTop={1} flexDirection="column">
964
+ <Text>Next steps:</Text>
965
+ <Text> cd {projectName}</Text>
966
+ <Text> pnpm install</Text>
967
+ <Text> pnpm dev</Text>
968
+ </Box>
969
+ </Box>
970
+ </Box>
971
+ );
972
+ }
973
+
974
+ if (step === 'processing') {
975
+ return (
976
+ <Box flexDirection="column">
977
+ {renderHeader()}
978
+ <Box>
979
+ <Spinner type="dots" />
980
+ <Text> {currentAction}</Text>
981
+ </Box>
982
+ </Box>
983
+ );
984
+ }
985
+
986
+ if (step === 'example-apps-loading') {
987
+ return (
988
+ <Box flexDirection="column">
989
+ {renderHeader()}
990
+ <Box>
991
+ <Spinner type="dots" />
992
+ <Text> {currentAction}</Text>
993
+ </Box>
994
+ </Box>
995
+ );
996
+ }
997
+
998
+ // ── Interactive steps — all share the persistent history header ───────────
999
+
1000
+ const renderCurrentStep = () => {
1001
+ switch (step) {
1002
+ case 'name':
1003
+ return (
1004
+ <Box flexDirection="column">
1005
+ <Text bold>Enter a name for your new Creatorem project:</Text>
1006
+ <TextInput
1007
+ value={projectName}
1008
+ onChange={setProjectName}
1009
+ onSubmit={handleNameSubmit}
1010
+ />
1011
+ {nameError && <Text color="red">{nameError}</Text>}
1012
+ </Box>
1013
+ );
1014
+
1015
+ case 'git-setup':
1016
+ return <Confirm question="Initialize a new git repository?" onConfirm={handleGitConfirm} />;
1017
+
1018
+ case 'app-marketing':
1019
+ return <Confirm question="Include a Marketing website?" onConfirm={handleMarketingConfirm} />;
1020
+
1021
+ case 'app-dashboard':
1022
+ return <Confirm question="Include a Dashboard (web app)?" onConfirm={handleDashboardConfirm} />;
1023
+
1024
+ case 'dashboard-features':
1025
+ return (
1026
+ <Box flexDirection="column">
1027
+ <Text bold>Deselect Dashboard features to remove:</Text>
1028
+ <Text color="gray" dimColor>Space to toggle · Enter to confirm</Text>
1029
+ <MultiSelect
1030
+ items={dashboardFeatureItems}
1031
+ initialSelectedValues={dashboardFeatures.map(f => f.key)}
1032
+ onSubmit={handleDashboardFeaturesSubmit}
1033
+ />
1034
+ </Box>
1035
+ );
1036
+
1037
+ case 'app-mobile':
1038
+ return <Confirm question="Include a Mobile app (Expo)?" onConfirm={handleMobileConfirm} />;
1039
+
1040
+ case 'mobile-features':
1041
+ return (
1042
+ <Box flexDirection="column">
1043
+ <Text bold>Deselect Mobile app features to remove:</Text>
1044
+ <Text color="gray" dimColor>Space to toggle · Enter to confirm</Text>
1045
+ <MultiSelect
1046
+ items={mobileFeatureItems}
1047
+ initialSelectedValues={mobileFeatures.map(f => f.key)}
1048
+ onSubmit={handleMobileFeaturesSubmit}
1049
+ />
1050
+ </Box>
1051
+ );
1052
+
1053
+ case 'app-example':
1054
+ return <Confirm question="Include example app(s)?" onConfirm={handleExampleConfirm} />;
1055
+
1056
+ case 'example-apps':
1057
+ return (
1058
+ <Box flexDirection="column">
1059
+ <Text bold>Select example app(s) to include:</Text>
1060
+ <Text color="gray" dimColor>Space to toggle · Enter to confirm</Text>
1061
+ <MultiSelect
1062
+ items={availableExampleApps.map(app => ({
1063
+ value: app.value,
1064
+ label: app.title,
1065
+ title: app.title,
1066
+ description: app.description,
1067
+ }))}
1068
+ onSubmit={handleExampleAppsSubmit}
1069
+ />
1070
+ </Box>
1071
+ );
1072
+
1073
+ case 'languages':
1074
+ return (
1075
+ <Box flexDirection="column">
1076
+ <Text bold>Select languages to support:</Text>
1077
+ <Box marginBottom={1}>
1078
+ <Text color="gray"> [x] </Text>
1079
+ <Text bold>English (en)</Text>
1080
+ <Text color="gray"> — always included</Text>
1081
+ </Box>
1082
+ <Text color="gray" dimColor>Space to toggle · Enter to confirm</Text>
1083
+ <MultiSelect
1084
+ items={OPTIONAL_LANGUAGE_ITEMS}
1085
+ initialSelectedValues={['fr']}
1086
+ onSubmit={handleLanguagesSubmit}
1087
+ />
1088
+ </Box>
1089
+ );
1090
+
1091
+ case 'languages-custom':
1092
+ return (
1093
+ <Box flexDirection="column">
1094
+ <Text bold>Add more languages?</Text>
1095
+ <Text color="gray" dimColor>
1096
+ Enter comma-separated language codes (e.g. de,es,pt) or press Enter to skip
1097
+ </Text>
1098
+ <Box>
1099
+ <Text color="cyan">{'> '}</Text>
1100
+ <Text>{customLangInput}</Text>
1101
+ <Text inverse> </Text>
1102
+ </Box>
1103
+ </Box>
1104
+ );
1105
+
1106
+ default:
1107
+ return null;
1108
+ }
1109
+ };
1110
+
1111
+ return (
1112
+ <Box flexDirection="column">
1113
+ {renderHeader()}
1114
+ {renderCurrentStep()}
1115
+ </Box>
1116
+ );
1117
+ };
1118
+
1119
+ export default Create;