@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
package/package.json CHANGED
@@ -1,17 +1,38 @@
1
1
  {
2
2
  "name": "@creatorem/cli",
3
- "version": "0.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "CLI tool for Creatorem SaaS Kit",
5
5
  "author": "Creatorem",
6
6
  "license": "MIT",
7
7
  "type": "module",
8
8
  "bin": {
9
- "creatorem": "./bin/cli.mjs"
9
+ "creatorem": "./dist/cli.js"
10
10
  },
11
- "publishConfig": {
12
- "access": "public"
11
+ "dependencies": {
12
+ "ink": "^6.7.0",
13
+ "ink-gradient": "^4.0.0",
14
+ "ink-spinner": "^5.0.0",
15
+ "ink-text-input": "^6.0.0",
16
+ "meow": "^14.0.0",
17
+ "pastel": "^4.0.0",
18
+ "react": "^19.2.4",
19
+ "fs-extra": "^11.2.0",
20
+ "ts-morph": "^25.0.1",
21
+ "execa": "^9.5.2"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^25.2.3",
25
+ "@types/react": "^19.2.14",
26
+ "tsup": "^8.5.1",
27
+ "tsx": "^4.21.0",
28
+ "typescript": "^5.9.3",
29
+ "@kit/tsconfig": "0.0.0"
13
30
  },
14
31
  "scripts": {
15
- "build": "echo 'no build needed'"
32
+ "clean": "git clean -xdf .turbo node_modules",
33
+ "build": "tsup",
34
+ "dev": "tsup --watch",
35
+ "start": "node dist/cli.js",
36
+ "test": "bash ./tests/test-cli-features.sh"
16
37
  }
17
38
  }
package/src/cli.tsx ADDED
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env node
2
+ import React from 'react';
3
+ import { render } from 'ink';
4
+ import meow from 'meow';
5
+ import readline from 'readline';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import App from './ui/app.js';
9
+
10
+ const cli = meow(
11
+ `
12
+ Usage
13
+ $ creatorem <command>
14
+
15
+ Commands
16
+ create Create a new Creatorem monorepo from scratch (use outside the repo)
17
+ generate-migration Generate a migration file from schemas
18
+ generate-schemas Generate schemas from setup.json
19
+ create-dashboard Add/configure a dashboard app inside an existing monorepo
20
+ create-mobile Add/configure a mobile app inside an existing monorepo
21
+ help Show help
22
+
23
+ Options
24
+ --version, -v Print the CLI version and exit
25
+ --help Show help
26
+ --edit Edit feature removals in-place (on current monorepo)
27
+ --features Comma-separated list of features to keep
28
+ --repo-scope Also clean up kit packages and supabase schemas in the monorepo root
29
+
30
+ Examples
31
+ $ creatorem create my-saas
32
+ $ creatorem create (interactive — prompts for name)
33
+ $ creatorem generate-migration
34
+ $ creatorem create-mobile --edit
35
+ $ creatorem generate-schemas apps/web/setup.json
36
+ $ creatorem create-dashboard my-app --features ai,keybindings --repo-scope
37
+ `,
38
+ {
39
+ importMeta: import.meta,
40
+ autoVersion: false, // handled manually to support both --version and -v
41
+ flags: {
42
+ version: { type: 'boolean', shortFlag: 'v', default: false },
43
+ edit: { type: 'boolean', default: false },
44
+ features: { type: 'string' },
45
+ repoScope: { type: 'boolean', default: false },
46
+ },
47
+ },
48
+ );
49
+
50
+ /** Walk up from `dir` looking for monorepo markers. Returns the root dir, or null. */
51
+ function detectMonorepoRoot(startDir: string): string | null {
52
+ const markers = ['pnpm-workspace.yaml', 'turbo.json', 'lerna.json', 'nx.json', 'rush.json'];
53
+ let current = startDir;
54
+ while (true) {
55
+ for (const marker of markers) {
56
+ if (fs.existsSync(path.join(current, marker))) {
57
+ return current;
58
+ }
59
+ }
60
+ // Also check package.json with workspaces field
61
+ const pkgPath = path.join(current, 'package.json');
62
+ if (fs.existsSync(pkgPath)) {
63
+ try {
64
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
65
+ if (pkg.workspaces) return current;
66
+ } catch { }
67
+ }
68
+ const parent = path.dirname(current);
69
+ if (parent === current) break; // filesystem root
70
+ current = parent;
71
+ }
72
+ return null;
73
+ }
74
+
75
+ function rl_question(prompt: string): Promise<string> {
76
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
77
+ return new Promise((resolve) => {
78
+ rl.question(prompt, (answer) => {
79
+ rl.close();
80
+ resolve(answer.trim());
81
+ });
82
+ });
83
+ }
84
+
85
+ async function main() {
86
+ if (cli.flags.version) {
87
+ process.stdout.write(`creatorem v${(cli.pkg as any).version}\n`);
88
+ process.exit(0);
89
+ }
90
+
91
+ const command = cli.input[0];
92
+ let args = cli.input.slice(1);
93
+
94
+ // 'create' always creates a fresh monorepo (outside-repo mode).
95
+ // 'create-dashboard' / 'create-mobile' operate on an existing monorepo.
96
+ const isCreateMonorepo = command === 'create';
97
+ const isCreateSubCommand = command === 'create-dashboard' || command === 'create-mobile';
98
+ const isCreateCommand = isCreateMonorepo || isCreateSubCommand;
99
+
100
+ const hasNameArg = !!args[0];
101
+ const hasFeaturesFlag = cli.flags.features !== undefined;
102
+ const isEditMode = !!(cli.flags as any).edit;
103
+
104
+ // Ask for project name via plain readline when running interactively (no name arg, no --features)
105
+ if (isCreateCommand && !hasNameArg && !hasFeaturesFlag && !isEditMode) {
106
+ const name = await rl_question('Project name: ');
107
+ if (!name) {
108
+ process.stderr.write('Project name is required\n');
109
+ process.exit(1);
110
+ }
111
+ args = [name, ...args];
112
+ }
113
+
114
+ // Monorepo detection: offer to place the app inside apps/ when name came from arg or readline.
115
+ // Only applies to create-dashboard / create-mobile (not 'create', which always starts fresh).
116
+ // Skip the interactive prompt in non-interactive mode (i.e. when --features is already supplied).
117
+ if (isCreateSubCommand && args[0] && !isEditMode && !hasFeaturesFlag) {
118
+ const cwd = process.cwd();
119
+ const monoRoot = detectMonorepoRoot(cwd);
120
+ const appsDir = monoRoot ? path.join(monoRoot, 'apps') : null;
121
+
122
+ if (appsDir && fs.existsSync(appsDir)) {
123
+ const appsRelative = path.relative(cwd, appsDir);
124
+ const suggestedPath = path.join(appsRelative, args[0]);
125
+ // Only prompt if the name doesn't already point inside apps/
126
+ if (!args[0].startsWith(appsRelative) && !path.isAbsolute(args[0])) {
127
+ const answer = await rl_question(
128
+ `Monorepo detected — create at ${suggestedPath}? [Y/n]: `
129
+ );
130
+ if (answer === '' || answer.toLowerCase() === 'y') {
131
+ args = [suggestedPath, ...args.slice(1)];
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ render(<App command={command} args={args} flags={cli.flags} />);
138
+ }
139
+
140
+ main();
141
+
@@ -0,0 +1,455 @@
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 { features } from '../dashboard-features/index.js';
12
+ import { FeatureRemover } from '../dashboard-features/types.js';
13
+ import { processFeature, processFeatureRepo, writeFeatureSelectionManifest } from '../dashboard-features/manager.js';
14
+ import { generateDashboardEnvTemplate } from '../utils/env-template.js';
15
+
16
+ const PRO_REPO = 'https://github.com/creatorem/creatorem-saas-kit';
17
+ const OSS_REPO = 'https://github.com/creatorem/creatorem-saas-kit-oss';
18
+
19
+ /**
20
+ * Returns true when `dir` is the root of a Creatorem monorepo:
21
+ * - has pnpm-workspace.yaml (pnpm workspace)
22
+ * - has turbo.json (Turborepo)
23
+ * - has kit/db, kit/shared, kit/utils packages
24
+ */
25
+ const isCreatoreMonorepo = (dir: string): boolean => {
26
+ return (
27
+ fs.existsSync(path.join(dir, 'pnpm-workspace.yaml')) &&
28
+ fs.existsSync(path.join(dir, 'turbo.json')) &&
29
+ fs.existsSync(path.join(dir, 'kit', 'db')) &&
30
+ fs.existsSync(path.join(dir, 'kit', 'shared')) &&
31
+ fs.existsSync(path.join(dir, 'kit', 'utils'))
32
+ );
33
+ };
34
+
35
+ /**
36
+ * Walk up from `startDir` looking for the nearest Creatorem monorepo root.
37
+ * Returns the root path or null if none found.
38
+ */
39
+ const findCreatoreMonorepoRoot = (startDir: string): string | null => {
40
+ let current = startDir;
41
+ while (true) {
42
+ if (isCreatoreMonorepo(current)) return current;
43
+ const parent = path.dirname(current);
44
+ if (parent === current) return null;
45
+ current = parent;
46
+ }
47
+ };
48
+
49
+ /** Detect the package manager from lock files. */
50
+ const detectPackageManager = (root: string): string => {
51
+ if (fs.existsSync(path.join(root, 'pnpm-lock.yaml'))) return 'pnpm';
52
+ if (fs.existsSync(path.join(root, 'yarn.lock'))) return 'yarn';
53
+ if (fs.existsSync(path.join(root, 'bun.lockb')) || fs.existsSync(path.join(root, 'bun.lock'))) return 'bun';
54
+ return 'npm';
55
+ };
56
+
57
+ const checkProAccess = async (): Promise<boolean> => {
58
+ try {
59
+ await execa('git', ['ls-remote', '--heads', PRO_REPO], { stdio: 'pipe', timeout: 10000 });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ };
65
+
66
+ /** Collect i18n namespace prefixes declared by a list of features. */
67
+ const collectFeatureNamespaces = (features: Array<{ i18nNamespacePrefix?: string | string[] }>): string[] =>
68
+ features.flatMap(f =>
69
+ f.i18nNamespacePrefix
70
+ ? (Array.isArray(f.i18nNamespacePrefix) ? f.i18nNamespacePrefix : [f.i18nNamespacePrefix])
71
+ : [],
72
+ );
73
+
74
+ /**
75
+ * Removes `namespacesToRemove` from the namespace array inside
76
+ * `applyCrossEnvFilter('cross_env_get_namespaces', [...])` in
77
+ * `{dashboardAppDir}/config/i18n.config.ts`.
78
+ */
79
+ export const updateDashboardI18nNamespaces = async (
80
+ dashboardAppDir: string,
81
+ namespacesToRemove: string[],
82
+ ): Promise<void> => {
83
+ if (namespacesToRemove.length === 0) return;
84
+
85
+ const i18nConfigPath = path.join(dashboardAppDir, 'config', 'i18n.config.ts');
86
+ if (!fs.existsSync(i18nConfigPath)) return;
87
+
88
+ let content = await fs.readFile(i18nConfigPath, 'utf-8');
89
+
90
+ content = content.replace(
91
+ /applyCrossEnvFilter\('cross_env_get_namespaces',\s*\[([^\]]*)\]\)/,
92
+ (_match, arrayContent: string) => {
93
+ const kept = arrayContent
94
+ .split(',')
95
+ .map((s: string) => s.trim().replace(/['"]/g, ''))
96
+ .filter((s: string) => s.length > 0 && !namespacesToRemove.includes(s));
97
+ const arrayStr = kept.map((s: string) => `\n '${s}'`).join(',') + (kept.length > 0 ? ',' : '');
98
+ return `applyCrossEnvFilter('cross_env_get_namespaces', [${arrayStr}\n])`;
99
+ },
100
+ );
101
+
102
+ await fs.writeFile(i18nConfigPath, content, 'utf-8');
103
+ };
104
+
105
+ interface CreateDashboardProps {
106
+ args: string[];
107
+ flags?: Record<string, any>;
108
+ }
109
+
110
+ export const CreateDashboard: React.FC<CreateDashboardProps> = ({ args, flags = {} }) => {
111
+ const { exit } = useApp();
112
+
113
+ // Check both args (for backward compatibility or direct passing) and flags (from meow)
114
+ const isEditMode = flags.edit || args.includes('--edit');
115
+
116
+ // In edit mode, default the name automatically so the name prompt is skipped.
117
+ const editDefaultName = isEditMode ? 'dashboard' : '';
118
+ const [projectName, setProjectName] = useState(args[0] || editDefaultName);
119
+
120
+ // Start at 'name' if no project name given, 'processing' if both name+features are pre-supplied,
121
+ // otherwise 'features' (name was passed as arg, just need feature selection).
122
+ const initialStep = (args[0] || editDefaultName)
123
+ ? (flags.features !== undefined ? 'processing' : 'features')
124
+ : 'name';
125
+
126
+ const [step, setStep] = useState<'name' | 'features' | 'processing' | 'done' | 'error'>(initialStep);
127
+
128
+ // Parse features from flag if present
129
+ const [selectedFeatures, setSelectedFeatures] = useState<string[]>(() => {
130
+ if (flags.features !== undefined) {
131
+ return typeof flags.features === 'string' && flags.features !== ''
132
+ ? flags.features.split(',').map(f => f.trim())
133
+ : [];
134
+ }
135
+ return [];
136
+ });
137
+ const [currentAction, setCurrentAction] = useState<string>('');
138
+ const [error, setError] = useState<string>('');
139
+ const [isOutsideRepoMode, setIsOutsideRepoMode] = useState(false);
140
+ const [detectedUserType, setDetectedUserType] = useState<'premium' | 'public' | null>(null);
141
+
142
+ const getFeatureTitle = (feature: FeatureRemover) => feature.cliUI.title;
143
+
144
+ const allFeatures = features.map(feature => ({
145
+ label: feature.cliUI.title,
146
+ value: feature.key,
147
+ title: feature.cliUI.title,
148
+ description: feature.cliUI.description,
149
+ features: feature.cliUI.features,
150
+ }));
151
+
152
+ const handleNameSubmit = (name: string) => {
153
+ if (!name) {
154
+ setError('Project name is required');
155
+ return;
156
+ }
157
+ setProjectName(name);
158
+ setStep('features');
159
+ setError('');
160
+ };
161
+
162
+
163
+
164
+ const handleFeaturesSubmit = async (selected: string[]) => {
165
+ fs.appendFileSync('debug.log', `Features selected: ${selected.join(', ')}\n`);
166
+ setSelectedFeatures(selected);
167
+ setStep('processing');
168
+
169
+ try {
170
+ fs.appendFileSync('debug.log', 'Starting project creation...\n');
171
+ await createAndModifyProject(projectName, selected);
172
+ fs.appendFileSync('debug.log', 'Project creation success.\n');
173
+ setStep('done');
174
+ // Give ink a moment to render the done state before exiting
175
+ setTimeout(() => {
176
+ exit();
177
+ }, 100);
178
+ } catch (err: any) {
179
+ fs.appendFileSync('debug.log', `Error: ${err.message}\n${err.stack}\n`);
180
+ setError(err.message);
181
+ setStep('error');
182
+ exit(err);
183
+ }
184
+ };
185
+
186
+ const createAndModifyProject = async (name: string, selected: string[]) => {
187
+ const targetDir = path.resolve(process.cwd(), name);
188
+ const selectedFeatureKeys = features
189
+ .filter(feature => selected.includes(feature.key))
190
+ .map(feature => feature.key);
191
+ const featuresToRemove = features.filter(f => !selectedFeatureKeys.includes(f.key));
192
+
193
+ // ── Detect whether we are inside a Creatorem monorepo ──────────────
194
+ const existingMonoRoot = isEditMode
195
+ ? findCreatoreMonorepoRoot(process.cwd()) // edit mode: use CWD
196
+ : findCreatoreMonorepoRoot(process.cwd());
197
+
198
+ if (existingMonoRoot) {
199
+ setDetectedUserType('premium');
200
+ // ── BRANCH A: inside a Creatorem monorepo ─────────────────────
201
+ // Find apps/dashboard in the detected monorepo.
202
+ const dashboardSource = path.join(existingMonoRoot, 'apps', 'dashboard');
203
+ if (!fs.existsSync(dashboardSource)) {
204
+ throw new Error(`Could not find apps/dashboard at ${dashboardSource}`);
205
+ }
206
+
207
+ // In edit mode, mutate apps/dashboard in place.
208
+ // In create mode, copy apps/dashboard into targetDir before applying removals.
209
+ const appTargetDir = isEditMode ? dashboardSource : targetDir;
210
+
211
+ // Copy dashboard to appTargetDir (skip when editing in place).
212
+ if (!isEditMode && appTargetDir !== dashboardSource) {
213
+ if (fs.existsSync(appTargetDir)) await fs.remove(appTargetDir);
214
+ setCurrentAction(`Copying dashboard to ${name}...`);
215
+ await fs.copy(dashboardSource, appTargetDir, {
216
+ filter: (src) => !src.includes('node_modules') && !src.includes('.next') && !src.includes('.turbo'),
217
+ });
218
+ }
219
+
220
+ // Apply app-level feature removals
221
+ for (const feature of featuresToRemove) {
222
+ setCurrentAction(`Removing feature: ${getFeatureTitle(feature)}...`);
223
+ await processFeature(feature, appTargetDir);
224
+ }
225
+
226
+ // If --repo-scope is active, clean kit packages + uninstall deps
227
+ const repoScope = flags['repo-scope'] || flags.repoScope;
228
+ if (repoScope) {
229
+ // 1. Uninstall deps FIRST — packages must still be present to be removed
230
+ const pm = detectPackageManager(existingMonoRoot);
231
+ const depsToUninstall = [...new Set(featuresToRemove.flatMap(f => f.dependenciesToRemove ?? []))];
232
+ if (depsToUninstall.length > 0) {
233
+ setCurrentAction(`[repo-scope] Uninstalling ${depsToUninstall.length} package(s) via ${pm}...`);
234
+ const removeCmd = pm === 'npm' ? 'uninstall' : 'remove';
235
+ const workspaceFlag = pm === 'pnpm' ? ['-r'] : pm === 'yarn' ? ['-W'] : pm === 'bun' ? [] : ['-w'];
236
+ try {
237
+ await execa(pm, [removeCmd, ...workspaceFlag, ...depsToUninstall], { cwd: existingMonoRoot, stdio: 'pipe' });
238
+ } catch { /* non-fatal */ }
239
+ }
240
+
241
+ // 2. Delete kit directories and run repo.apply hooks
242
+ for (const feature of featuresToRemove) {
243
+ setCurrentAction(`[repo-scope] Cleaning kit/${feature.key}...`);
244
+ await processFeatureRepo(feature, existingMonoRoot);
245
+ }
246
+ }
247
+
248
+ // Generate .env.template from the (now-trimmed) envs.ts
249
+ setCurrentAction('Generating .env.template...');
250
+ await generateDashboardEnvTemplate(appTargetDir, existingMonoRoot, {
251
+ createEnvFile: !isEditMode,
252
+ fallbackMonoRoot: process.cwd(),
253
+ });
254
+
255
+ setCurrentAction('Saving selected features metadata...');
256
+ await writeFeatureSelectionManifest(appTargetDir, 'dashboard', selectedFeatureKeys);
257
+
258
+ const removedNamespacesA = collectFeatureNamespaces(featuresToRemove);
259
+ if (removedNamespacesA.length > 0) {
260
+ setCurrentAction('Updating i18n namespaces...');
261
+ await updateDashboardI18nNamespaces(appTargetDir, removedNamespacesA);
262
+ }
263
+
264
+ if (!isEditMode) {
265
+ const pkgPath = path.join(appTargetDir, 'package.json');
266
+ if (fs.existsSync(pkgPath)) {
267
+ const pkg = await fs.readJson(pkgPath);
268
+ pkg.name = path.basename(name);
269
+ await fs.writeJson(pkgPath, pkg, { spaces: 4 });
270
+ }
271
+ }
272
+
273
+ } else {
274
+ // ── BRANCH B: outside a Creatorem monorepo ────────────────────
275
+ // --repo-scope and --edit are always implied in this mode:
276
+ // • repo-scope: always clean kit packages and uninstall deps
277
+ // • edit: no package.json rename (the whole monorepo IS the output)
278
+ // • only apps/dashboard is kept; all other apps/* dirs are removed
279
+ setIsOutsideRepoMode(true);
280
+
281
+ const tmpDir = path.join(os.tmpdir(), `creatorem-saas-kit-${Date.now()}`);
282
+
283
+ try {
284
+ setCurrentAction('Checking repository access...');
285
+ const hasPro = await checkProAccess();
286
+ const repoUrl = hasPro ? PRO_REPO : OSS_REPO;
287
+ setDetectedUserType(hasPro ? 'premium' : 'public');
288
+ setCurrentAction(
289
+ hasPro
290
+ ? 'Premium user detected. Using private Creatorem repository...'
291
+ : 'Public user detected. Using OSS Creatorem repository...',
292
+ );
293
+
294
+ setCurrentAction('Cloning Creatorem template repo (this may take a moment)...');
295
+ await execa('git', ['clone', '--depth', '1', repoUrl, tmpDir], { stdio: 'pipe' });
296
+
297
+ const clonedDashboard = path.join(tmpDir, 'apps', 'dashboard');
298
+ if (!fs.existsSync(clonedDashboard)) {
299
+ throw new Error('Template repo does not contain apps/dashboard — please report this issue.');
300
+ }
301
+
302
+ // 1. Delete all apps/* except apps/dashboard
303
+ setCurrentAction('Removing non-dashboard apps...');
304
+ const appsDir = path.join(tmpDir, 'apps');
305
+ const appEntries = await fs.readdir(appsDir);
306
+ for (const entry of appEntries) {
307
+ if (entry !== 'dashboard') {
308
+ await fs.remove(path.join(appsDir, entry));
309
+ }
310
+ }
311
+
312
+ // 2. Uninstall removed packages FIRST — must be present before deletion
313
+ const pm = detectPackageManager(tmpDir);
314
+ const depsToUninstall = [...new Set(featuresToRemove.flatMap(f => f.dependenciesToRemove ?? []))];
315
+ if (depsToUninstall.length > 0) {
316
+ setCurrentAction(`Uninstalling ${depsToUninstall.length} package(s) via ${pm}...`);
317
+ const removeCmd = pm === 'npm' ? 'uninstall' : 'remove';
318
+ const workspaceFlag = pm === 'pnpm' ? ['-r'] : pm === 'yarn' ? ['-W'] : pm === 'bun' ? [] : ['-w'];
319
+ try {
320
+ await execa(pm, [removeCmd, ...workspaceFlag, ...depsToUninstall], { cwd: tmpDir, stdio: 'pipe' });
321
+ } catch { /* non-fatal */ }
322
+ }
323
+
324
+ // 3. Apply app-level feature removals on the cloned dashboard
325
+ for (const feature of featuresToRemove) {
326
+ setCurrentAction(`Removing feature: ${getFeatureTitle(feature)}...`);
327
+ await processFeature(feature, clonedDashboard);
328
+ }
329
+
330
+ // 4. Delete kit directories (repo-scope always on)
331
+ for (const feature of featuresToRemove) {
332
+ setCurrentAction(`[repo-scope] Cleaning kit/${feature.key}...`);
333
+ await processFeatureRepo(feature, tmpDir);
334
+ }
335
+
336
+ // Generate .env.template from the (now-trimmed) envs.ts
337
+ setCurrentAction('Generating .env.template...');
338
+ await generateDashboardEnvTemplate(clonedDashboard, tmpDir, {
339
+ createEnvFile: true,
340
+ fallbackMonoRoot: process.cwd(),
341
+ });
342
+
343
+ setCurrentAction('Saving selected features metadata...');
344
+ await writeFeatureSelectionManifest(clonedDashboard, 'dashboard', selectedFeatureKeys);
345
+
346
+ const removedNamespacesB = collectFeatureNamespaces(featuresToRemove);
347
+ if (removedNamespacesB.length > 0) {
348
+ setCurrentAction('Updating i18n namespaces...');
349
+ await updateDashboardI18nNamespaces(clonedDashboard, removedNamespacesB);
350
+ }
351
+
352
+ // Move cloned+stripped monorepo to the user's target path
353
+ setCurrentAction(`Finalizing monorepo at ${name}...`);
354
+ if (fs.existsSync(targetDir)) await fs.remove(targetDir);
355
+ await fs.move(tmpDir, targetDir);
356
+
357
+ } catch (err) {
358
+ // Clean up temp dir on failure
359
+ if (fs.existsSync(tmpDir)) await fs.remove(tmpDir).catch(() => { });
360
+ throw err;
361
+ }
362
+ }
363
+
364
+
365
+ setCurrentAction('Done.');
366
+ };
367
+
368
+ // Auto-exec if initialized in processing state (via flags)
369
+ useEffect(() => {
370
+ if (initialStep === 'processing' && flags.features !== undefined) {
371
+ const run = async () => {
372
+ try {
373
+ const featuresToSelect = typeof flags.features === 'string' && flags.features !== ''
374
+ ? flags.features.split(',').map(f => f.trim())
375
+ : [];
376
+ fs.appendFileSync('debug.log', `Features selected (via flag): ${featuresToSelect.join(', ')}\n`);
377
+ fs.appendFileSync('debug.log', 'Starting project creation...\n');
378
+ await createAndModifyProject(projectName, featuresToSelect);
379
+ fs.appendFileSync('debug.log', 'Project creation success.\n');
380
+ setStep('done');
381
+ setTimeout(() => {
382
+ exit();
383
+ }, 100);
384
+ } catch (err: any) {
385
+ fs.appendFileSync('debug.log', `Error: ${err.message}\n${err.stack}\n`);
386
+ setError(err.message);
387
+ setStep('error');
388
+ exit(err);
389
+ }
390
+ };
391
+ run();
392
+ }
393
+ }, []); // Run once on mount
394
+
395
+
396
+ if (step === 'error') {
397
+ return <Text color="red">Error: {error}</Text>;
398
+ }
399
+
400
+ if (step === 'done') {
401
+ return (
402
+ <Box flexDirection="column">
403
+ <Gradient name="pastel">
404
+ <Text>
405
+ {isOutsideRepoMode
406
+ ? `Monorepo "${projectName}" created successfully!`
407
+ : `Dashboard "${projectName}" created successfully!`}
408
+ </Text>
409
+ </Gradient>
410
+ {detectedUserType && (
411
+ <Text color="gray">
412
+ {detectedUserType === 'premium'
413
+ ? 'Detected user type: Premium'
414
+ : 'Detected user type: Public (using OSS repository)'}
415
+ </Text>
416
+ )}
417
+ <Text>Next steps:</Text>
418
+ <Text> cd {projectName}</Text>
419
+ <Text> pnpm install</Text>
420
+ <Text> pnpm dev</Text>
421
+ </Box>
422
+ );
423
+ }
424
+
425
+ if (step === 'processing') {
426
+ return (
427
+ <Box>
428
+ <Spinner type="dots" />
429
+ <Text> {currentAction}</Text>
430
+ </Box>
431
+ );
432
+ }
433
+
434
+ if (step === 'name') {
435
+ return (
436
+ <Box flexDirection="column">
437
+ <Text>Enter project name:</Text>
438
+ <TextInput value={projectName} onChange={setProjectName} onSubmit={handleNameSubmit} />
439
+ {error && <Text color="red">{error}</Text>}
440
+ </Box>
441
+ );
442
+ }
443
+
444
+ return (
445
+ <Box flexDirection="column">
446
+ <Text>Select features to include:</Text>
447
+ <MultiSelect
448
+ items={allFeatures}
449
+ onSubmit={handleFeaturesSubmit}
450
+ />
451
+ </Box>
452
+ );
453
+ };
454
+
455
+ export default CreateDashboard;