@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,555 @@
1
+ import React, { useState, useEffect } from 'react';
2
+ import { Box, Text, useApp } 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 { mobileFeatures } from '../mobile-features/index.js';
12
+ import { processFeature, processFeatureRepo, writeFeatureSelectionManifest } from '../mobile-features/manager.js';
13
+ import { generateDashboardEnvTemplate } from '../utils/env-template.js';
14
+
15
+ const PRO_REPO = 'https://github.com/creatorem/creatorem-saas-kit';
16
+ const OSS_REPO = 'https://github.com/creatorem/creatorem-saas-kit-oss';
17
+
18
+ /**
19
+ * Returns true when `dir` is the root of a Creatorem monorepo:
20
+ * - has pnpm-workspace.yaml (pnpm workspace)
21
+ * - has turbo.json (Turborepo)
22
+ * - has kit/db, kit/shared, kit/utils packages
23
+ */
24
+ const isCreatoreMonorepo = (dir: string): boolean => {
25
+ return (
26
+ fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) &&
27
+ fs.existsSync(path.join(dir, 'turbo.json')) &&
28
+ fs.existsSync(path.join(dir, 'kit', 'db')) &&
29
+ fs.existsSync(path.join(dir, 'kit', 'shared')) &&
30
+ fs.existsSync(path.join(dir, 'kit', 'utils'))
31
+ );
32
+ };
33
+
34
+ /**
35
+ * Walk up from `startDir` looking for the nearest Creatorem monorepo root.
36
+ * Returns the root path or null if none found.
37
+ */
38
+ const findCreatoreMonorepoRoot = (startDir: string): string | null => {
39
+ let current = startDir;
40
+ while (true) {
41
+ if (isCreatoreMonorepo(current)) return current;
42
+ const parent = path.dirname(current);
43
+ if (parent === current) return null;
44
+ current = parent;
45
+ }
46
+ };
47
+
48
+ /** Detect the package manager from lock files. */
49
+ const detectPackageManager = (root: string): string => {
50
+ if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
51
+ if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
52
+ if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock'))) return 'bun';
53
+ return 'npm';
54
+ };
55
+
56
+ const checkProAccess = async (): Promise<boolean> => {
57
+ try {
58
+ await execa('git', ['ls-remote', '--heads', PRO_REPO], { stdio: 'pipe', timeout: 10000 });
59
+ return true;
60
+ } catch {
61
+ return false;
62
+ }
63
+ };
64
+
65
+ const copyApiAppFromTemplateRepo = async (repoUrl: string, targetApiDir: string) => {
66
+ const tmpDir = path.join(os.tmpdir(), `creatorem-api-template-${Date.now()}`);
67
+ try {
68
+ await execa('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe' });
69
+
70
+ const sourceApiDir = path.join(tmpDir, 'apps', 'api');
71
+ if (!fs.existsSync(sourceApiDir)) {
72
+ throw new Error('Template repo does not contain apps/api — please report this issue.');
73
+ }
74
+
75
+ await fs.ensureDir(path.dirname(targetApiDir));
76
+ await fs.copy(sourceApiDir, targetApiDir, {
77
+ filter: (src) => !src.includes('node_modules') && !src.includes('.next') && !src.includes('.turbo'),
78
+ });
79
+ } finally {
80
+ if (fs.existsSync(tmpDir)) {
81
+ await fs.remove(tmpDir).catch(() => { });
82
+ }
83
+ }
84
+ };
85
+
86
+ /** Capitalises the first letter of a string (used for import variable names). */
87
+ const capitalizeFirst = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1);
88
+
89
+ /** Collect the i18n namespace prefixes declared by a list of features. */
90
+ export const collectFeatureNamespaces = (features: Array<{ i18nNamespacePrefix?: string | string[] }>): string[] =>
91
+ features.flatMap(f =>
92
+ f.i18nNamespacePrefix
93
+ ? (Array.isArray(f.i18nNamespacePrefix) ? f.i18nNamespacePrefix : [f.i18nNamespacePrefix])
94
+ : [],
95
+ );
96
+
97
+ /**
98
+ * Removes `namespacesToRemove` from the `namespaces: [...]` array in
99
+ * `{mobileAppDir}/config/i18n.config.ts`.
100
+ * No-op when the array or the file cannot be found.
101
+ */
102
+ export const updateMobileI18nNamespaces = async (
103
+ mobileAppDir: string,
104
+ namespacesToRemove: string[],
105
+ ): Promise<void> => {
106
+ if (namespacesToRemove.length === 0) return;
107
+
108
+ const i18nConfigPath = path.join(mobileAppDir, 'config', 'i18n.config.ts');
109
+ if (!fs.existsSync(i18nConfigPath)) return;
110
+
111
+ let content = await fs.readFile(i18nConfigPath, 'utf-8');
112
+
113
+ content = content.replace(
114
+ /namespaces:\s*\[([^\]]*)\]/,
115
+ (_match, arrayContent: string) => {
116
+ const kept = arrayContent
117
+ .split(',')
118
+ .map((s: string) => s.trim().replace(/['"]/g, ''))
119
+ .filter((s: string) => s.length > 0 && !namespacesToRemove.includes(s));
120
+ return `namespaces: [${kept.map((s: string) => `'${s}'`).join(', ')}]`;
121
+ },
122
+ );
123
+
124
+ await fs.writeFile(i18nConfigPath, content, 'utf-8');
125
+ };
126
+
127
+ /**
128
+ * Updates the mobile app's i18n.config.ts to match `selectedLanguages`.
129
+ * - Removes locale imports and translation entries for excluded languages.
130
+ * - Adds locale imports and translation entries for newly-added languages.
131
+ * - Optionally removes namespaces from the `namespaces: [...]` array.
132
+ * Assumes `{mobileAppDir}/locales/en/` always exists.
133
+ */
134
+ export const updateMobileI18nConfig = async (
135
+ mobileAppDir: string,
136
+ selectedLanguages: string[],
137
+ namespacesToRemove: string[] = [],
138
+ ): Promise<void> => {
139
+ const i18nConfigPath = path.join(mobileAppDir, 'config', 'i18n.config.ts');
140
+ if (!fs.existsSync(i18nConfigPath)) return;
141
+
142
+ const localesEnDir = path.join(mobileAppDir, 'locales', 'en');
143
+ if (!fs.existsSync(localesEnDir)) return;
144
+
145
+ // Discover namespaces from en locale dir (e.g. common, notification)
146
+ const namespaces = (await fs.readdir(localesEnDir))
147
+ .filter((f: string) => f.endsWith('.json'))
148
+ .map((f: string) => path.basename(f, '.json'));
149
+
150
+ // Build locale import statements
151
+ const newLocaleImports = selectedLanguages
152
+ .flatMap(lang =>
153
+ namespaces.map(
154
+ ns => `import ${lang}${capitalizeFirst(ns)} from '../locales/${lang}/${ns}.json';`,
155
+ ),
156
+ )
157
+ .join('\n');
158
+
159
+ // Build translations object entries
160
+ const translationsEntries = selectedLanguages
161
+ .map(lang => {
162
+ const nsEntries = namespaces
163
+ .map(ns => ` ${ns}: ${lang}${capitalizeFirst(ns)},`)
164
+ .join('\n');
165
+ return ` ${lang}: {\n${nsEntries}\n },`;
166
+ })
167
+ .join('\n');
168
+
169
+ let content = await fs.readFile(i18nConfigPath, 'utf-8');
170
+
171
+ // 1. Strip all existing locale JSON imports
172
+ content = content.replace(
173
+ /^import \w+ from '\.\.\/locales\/[^']+\.json';\n?/gm,
174
+ '',
175
+ );
176
+
177
+ // 2. Replace the translations const block
178
+ content = content.replace(
179
+ /const translations = \{[\s\S]*?\n\};/,
180
+ `const translations = {\n${translationsEntries}\n};`,
181
+ );
182
+
183
+ // 3. Re-insert locale imports just before the translations comment
184
+ content = content.replace(
185
+ /\n(\/\/ Create a mapping object)/,
186
+ `\n${newLocaleImports}\n\n$1`,
187
+ );
188
+
189
+ await fs.writeFile(i18nConfigPath, content, 'utf-8');
190
+
191
+ // 4. Remove feature-driven namespaces from the namespaces array
192
+ await updateMobileI18nNamespaces(mobileAppDir, namespacesToRemove);
193
+ };
194
+
195
+ interface CreateMobileProps {
196
+ args: string[];
197
+ flags?: Record<string, any>;
198
+ }
199
+
200
+ export const CreateMobile: React.FC<CreateMobileProps> = ({ args, flags = {} }) => {
201
+ const { exit } = useApp();
202
+
203
+ const isEditMode = flags.edit || args.includes('--edit');
204
+ const editDefaultName = isEditMode ? 'mobile' : '';
205
+ const [projectName, setProjectName] = useState(args[0] || editDefaultName);
206
+
207
+ const initialStep = (args[0] || editDefaultName)
208
+ ? (flags.features !== undefined ? 'processing' : 'features')
209
+ : 'name';
210
+
211
+ const [step, setStep] = useState<'name' | 'features' | 'processing' | 'done' | 'error'>(initialStep);
212
+
213
+ const [selectedFeatures, setSelectedFeatures] = useState<string[]>(() => {
214
+ if (flags.features !== undefined) {
215
+ return typeof flags.features === 'string' && flags.features !== ''
216
+ ? flags.features.split(',').map((f: string) => f.trim())
217
+ : [];
218
+ }
219
+ return [];
220
+ });
221
+ const [currentAction, setCurrentAction] = useState<string>('');
222
+ const [error, setError] = useState<string>('');
223
+ const [isOutsideRepoMode, setIsOutsideRepoMode] = useState(false);
224
+ const [detectedUserType, setDetectedUserType] = useState<'premium' | 'public' | null>(null);
225
+
226
+ const getFeatureTitle = (feature: (typeof mobileFeatures)[number]) =>
227
+ feature.cliUI.title;
228
+
229
+ const allFeatures = mobileFeatures.map(feature => ({
230
+ label: feature.cliUI.title,
231
+ value: feature.key,
232
+ title: feature.cliUI.title,
233
+ description: feature.cliUI.description,
234
+ features: feature.cliUI.features,
235
+ }));
236
+
237
+ const handleNameSubmit = (name: string) => {
238
+ if (!name) {
239
+ setError('Project name is required');
240
+ return;
241
+ }
242
+ setProjectName(name);
243
+ setStep('features');
244
+ setError('');
245
+ };
246
+
247
+ const handleFeaturesSubmit = async (selected: string[]) => {
248
+ setSelectedFeatures(selected);
249
+ setStep('processing');
250
+
251
+ try {
252
+ await createAndModifyProject(projectName, selected);
253
+ setStep('done');
254
+ setTimeout(() => { exit(); }, 100);
255
+ } catch (err: any) {
256
+ setError(err.message);
257
+ setStep('error');
258
+ exit(err);
259
+ }
260
+ };
261
+
262
+ const createAndModifyProject = async (name: string, selected: string[]) => {
263
+ const targetDir = path.resolve(process.cwd(), name);
264
+ const selectedFeatureKeys = mobileFeatures
265
+ .filter(feature => selected.includes(feature.key))
266
+ .map(feature => feature.key);
267
+ const featuresToRemove = mobileFeatures.filter(f => !selectedFeatureKeys.includes(f.key));
268
+ const repoScope = flags['repo-scope'] || flags.repoScope;
269
+
270
+ const existingMonoRoot = findCreatoreMonorepoRoot(process.cwd());
271
+
272
+ if (existingMonoRoot) {
273
+ setDetectedUserType('premium');
274
+ // ── BRANCH A: inside a Creatorem monorepo ─────────────────────
275
+ const mobileSource = path.join(existingMonoRoot, 'apps', 'mobile');
276
+ if (!fs.existsSync(mobileSource)) {
277
+ throw new Error(`Could not find apps/mobile at ${mobileSource}`);
278
+ }
279
+ const dashboardAppDir = path.join(existingMonoRoot, 'apps', 'dashboard');
280
+ const hasDashboardApp = fs.existsSync(dashboardAppDir);
281
+
282
+ const appTargetDir = isEditMode ? mobileSource : targetDir;
283
+
284
+ if (!isEditMode && appTargetDir !== mobileSource) {
285
+ if (fs.existsSync(appTargetDir)) await fs.remove(appTargetDir);
286
+ setCurrentAction(`Copying mobile app to ${name}...`);
287
+ await fs.copy(mobileSource, appTargetDir, {
288
+ filter: (src) => !src.includes('node_modules') && !src.includes('.expo') && !src.includes('.turbo'),
289
+ });
290
+ }
291
+
292
+ // Apply app-level feature removals
293
+ for (const feature of featuresToRemove) {
294
+ setCurrentAction(`Removing feature: ${getFeatureTitle(feature)}...`);
295
+ await processFeature(feature, appTargetDir, 'mobile');
296
+ }
297
+
298
+ // If --repo-scope is active, always remove packages/pco-shared,
299
+ // then run feature-driven repo cleanup when dashboard exists.
300
+ if (repoScope) {
301
+ const pcoSharedPackageDir = path.join(existingMonoRoot, 'packages', 'pco-shared');
302
+ if (fs.existsSync(pcoSharedPackageDir)) {
303
+ setCurrentAction('[repo-scope] Removing packages/pco-shared...');
304
+ await fs.remove(pcoSharedPackageDir);
305
+ }
306
+
307
+ if (hasDashboardApp) {
308
+ const pm = detectPackageManager(existingMonoRoot);
309
+ const depsToUninstall = [...new Set(featuresToRemove.flatMap(f => f.dependenciesToRemove ?? []))];
310
+ if (depsToUninstall.length > 0) {
311
+ setCurrentAction(`[repo-scope] Uninstalling ${depsToUninstall.length} package(s) via ${pm}...`);
312
+ const removeCmd = pm === 'npm' ? 'uninstall' : 'remove';
313
+ const workspaceFlag = pm === 'pnpm' ? ['-r'] : pm === 'yarn' ? ['-W'] : pm === 'bun' ? [] : ['-w'];
314
+ try {
315
+ await execa(pm, [removeCmd, ...workspaceFlag, ...depsToUninstall], { cwd: existingMonoRoot, stdio: 'pipe' });
316
+ } catch { /* non-fatal */ }
317
+ }
318
+
319
+ for (const feature of featuresToRemove) {
320
+ setCurrentAction(`[repo-scope] Cleaning kit/${feature.key}...`);
321
+ await processFeatureRepo(feature, existingMonoRoot);
322
+ }
323
+ } else {
324
+ setCurrentAction('[repo-scope] Skipping feature repo cleanup (apps/api backend mode)...');
325
+ }
326
+ }
327
+
328
+ // Generate .env.template from the (now-trimmed) envs.ts
329
+ setCurrentAction('Generating .env.template...');
330
+ await generateDashboardEnvTemplate(appTargetDir, existingMonoRoot, {
331
+ createEnvFile: !isEditMode,
332
+ fallbackMonoRoot: process.cwd(),
333
+ });
334
+
335
+ setCurrentAction('Saving selected features metadata...');
336
+ await writeFeatureSelectionManifest(appTargetDir, 'mobile', selectedFeatureKeys);
337
+
338
+ const removedNamespacesA = collectFeatureNamespaces(featuresToRemove);
339
+ if (removedNamespacesA.length > 0) {
340
+ setCurrentAction('Updating i18n namespaces...');
341
+ await updateMobileI18nNamespaces(appTargetDir, removedNamespacesA);
342
+ }
343
+
344
+ if (!hasDashboardApp) {
345
+ const apiAppDir = path.join(existingMonoRoot, 'apps', 'api');
346
+ if (!fs.existsSync(apiAppDir)) {
347
+ setCurrentAction('No dashboard found. Adding apps/api backend...');
348
+ const hasPro = await checkProAccess();
349
+ const repoUrl = hasPro ? PRO_REPO : OSS_REPO;
350
+ await copyApiAppFromTemplateRepo(repoUrl, apiAppDir);
351
+ }
352
+
353
+ const trpcRoutePath = path.join(apiAppDir, 'app', 'api', 'trpc', '[trpc]', 'route.ts');
354
+ if (!fs.existsSync(trpcRoutePath)) {
355
+ throw new Error('apps/api is missing app/api/trpc/[trpc]/route.ts in template repo.');
356
+ }
357
+
358
+ for (const feature of featuresToRemove) {
359
+ setCurrentAction(`[api] Removing feature: ${getFeatureTitle(feature)}...`);
360
+ await processFeature(feature, apiAppDir, 'dashboard');
361
+ }
362
+
363
+ setCurrentAction('[api] Generating .env.template...');
364
+ await generateDashboardEnvTemplate(apiAppDir, existingMonoRoot, {
365
+ createEnvFile: true,
366
+ fallbackMonoRoot: process.cwd(),
367
+ });
368
+ }
369
+
370
+ if (!isEditMode) {
371
+ const pkgPath = path.join(appTargetDir, 'package.json');
372
+ if (fs.existsSync(pkgPath)) {
373
+ const pkg = await fs.readJson(pkgPath);
374
+ pkg.name = path.basename(name);
375
+ await fs.writeJson(pkgPath, pkg, { spaces: 4 });
376
+ }
377
+ }
378
+
379
+ } else {
380
+ // ── BRANCH B: outside a Creatorem monorepo ────────────────────
381
+ setIsOutsideRepoMode(true);
382
+
383
+ const tmpDir = path.join(os.tmpdir(), `creatorem-saas-kit-${Date.now()}`);
384
+
385
+ try {
386
+ setCurrentAction('Checking repository access...');
387
+ const hasPro = await checkProAccess();
388
+ const repoUrl = hasPro ? PRO_REPO : OSS_REPO;
389
+ setDetectedUserType(hasPro ? 'premium' : 'public');
390
+ setCurrentAction(
391
+ hasPro
392
+ ? 'Premium user detected. Using private Creatorem repository...'
393
+ : 'Public user detected. Using OSS Creatorem repository...',
394
+ );
395
+
396
+ setCurrentAction('Cloning Creatorem template repo (this may take a moment)...');
397
+ await execa('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe' });
398
+
399
+ const clonedMobile = path.join(tmpDir, 'apps', 'mobile');
400
+ if (!fs.existsSync(clonedMobile)) {
401
+ throw new Error('Template repo does not contain apps/mobile — please report this issue.');
402
+ }
403
+ const clonedApi = path.join(tmpDir, 'apps', 'api');
404
+ if (!fs.existsSync(clonedApi)) {
405
+ throw new Error('Template repo does not contain apps/api — please report this issue.');
406
+ }
407
+ const trpcRoutePath = path.join(clonedApi, 'app', 'api', 'trpc', '[trpc]', 'route.ts');
408
+ if (!fs.existsSync(trpcRoutePath)) {
409
+ throw new Error('apps/api is missing app/api/trpc/[trpc]/route.ts in template repo.');
410
+ }
411
+
412
+ // 1. Delete all apps/* except apps/mobile and apps/api
413
+ setCurrentAction('Removing apps except mobile and api...');
414
+ const appsDir = path.join(tmpDir, 'apps');
415
+ const appEntries = await fs.readdir(appsDir);
416
+ for (const entry of appEntries) {
417
+ if (entry !== 'mobile' && entry !== 'api') {
418
+ await fs.remove(path.join(appsDir, entry));
419
+ }
420
+ }
421
+
422
+ // 2. Apply app-level feature removals on mobile + api apps
423
+ for (const feature of featuresToRemove) {
424
+ setCurrentAction(`Removing feature: ${getFeatureTitle(feature)}...`);
425
+ await processFeature(feature, clonedMobile, 'mobile');
426
+ }
427
+ for (const feature of featuresToRemove) {
428
+ setCurrentAction(`[api] Removing feature: ${getFeatureTitle(feature)}...`);
429
+ await processFeature(feature, clonedApi, 'dashboard');
430
+ }
431
+
432
+ // Generate .env.template from the (now-trimmed) envs.ts
433
+ setCurrentAction('Generating .env.template...');
434
+ await generateDashboardEnvTemplate(clonedMobile, tmpDir, {
435
+ createEnvFile: true,
436
+ fallbackMonoRoot: process.cwd(),
437
+ });
438
+ setCurrentAction('[api] Generating .env.template...');
439
+ await generateDashboardEnvTemplate(clonedApi, tmpDir, {
440
+ createEnvFile: true,
441
+ fallbackMonoRoot: process.cwd(),
442
+ });
443
+
444
+ setCurrentAction('Saving selected features metadata...');
445
+ await writeFeatureSelectionManifest(clonedMobile, 'mobile', selectedFeatureKeys);
446
+
447
+ const removedNamespacesB = collectFeatureNamespaces(featuresToRemove);
448
+ if (removedNamespacesB.length > 0) {
449
+ setCurrentAction('Updating i18n namespaces...');
450
+ await updateMobileI18nNamespaces(clonedMobile, removedNamespacesB);
451
+ }
452
+
453
+ if (repoScope) {
454
+ const pcoSharedPackageDir = path.join(tmpDir, 'packages', 'pco-shared');
455
+ if (fs.existsSync(pcoSharedPackageDir)) {
456
+ setCurrentAction('[repo-scope] Removing packages/pco-shared...');
457
+ await fs.remove(pcoSharedPackageDir);
458
+ }
459
+ }
460
+
461
+ // Move cloned+stripped monorepo to the user's target path
462
+ setCurrentAction(`Finalizing monorepo at ${name}...`);
463
+ if (fs.existsSync(targetDir)) await fs.remove(targetDir);
464
+ await fs.move(tmpDir, targetDir);
465
+
466
+ } catch (err) {
467
+ if (fs.existsSync(tmpDir)) await fs.remove(tmpDir).catch(() => { });
468
+ throw err;
469
+ }
470
+ }
471
+
472
+ setCurrentAction('Done.');
473
+ };
474
+
475
+ // Auto-exec when initialized directly in processing state (via --features flag)
476
+ useEffect(() => {
477
+ if (initialStep === 'processing' && flags.features !== undefined) {
478
+ const run = async () => {
479
+ try {
480
+ const featuresToSelect = typeof flags.features === 'string' && flags.features !== ''
481
+ ? flags.features.split(',').map((f: string) => f.trim())
482
+ : [];
483
+ await createAndModifyProject(projectName, featuresToSelect);
484
+ setStep('done');
485
+ setTimeout(() => { exit(); }, 100);
486
+ } catch (err: any) {
487
+ setError(err.message);
488
+ setStep('error');
489
+ exit(err);
490
+ }
491
+ };
492
+ run();
493
+ }
494
+ }, []); // Run once on mount
495
+
496
+ if (step === 'error') {
497
+ return <Text color="red">Error: {error}</Text>;
498
+ }
499
+
500
+ if (step === 'done') {
501
+ return (
502
+ <Box flexDirection="column">
503
+ <Gradient name="pastel">
504
+ <Text>
505
+ {isOutsideRepoMode
506
+ ? `Monorepo "${projectName}" created successfully!`
507
+ : `Mobile app "${projectName}" created successfully!`}
508
+ </Text>
509
+ </Gradient>
510
+ {detectedUserType && (
511
+ <Text color="gray">
512
+ {detectedUserType === 'premium'
513
+ ? 'Detected user type: Premium'
514
+ : 'Detected user type: Public (using OSS repository)'}
515
+ </Text>
516
+ )}
517
+ <Text>Next steps:</Text>
518
+ <Text> cd {projectName}</Text>
519
+ <Text> pnpm install</Text>
520
+ <Text> pnpm dev</Text>
521
+ </Box>
522
+ );
523
+ }
524
+
525
+ if (step === 'processing') {
526
+ return (
527
+ <Box>
528
+ <Spinner type="dots" />
529
+ <Text> {currentAction}</Text>
530
+ </Box>
531
+ );
532
+ }
533
+
534
+ if (step === 'name') {
535
+ return (
536
+ <Box flexDirection="column">
537
+ <Text>Enter project name:</Text>
538
+ <TextInput value={projectName} onChange={setProjectName} onSubmit={handleNameSubmit} />
539
+ {error && <Text color="red">{error}</Text>}
540
+ </Box>
541
+ );
542
+ }
543
+
544
+ return (
545
+ <Box flexDirection="column">
546
+ <Text>Select features to include:</Text>
547
+ <MultiSelect
548
+ items={allFeatures}
549
+ onSubmit={handleFeaturesSubmit}
550
+ />
551
+ </Box>
552
+ );
553
+ };
554
+
555
+ export default CreateMobile;