@creatorem/cli 0.0.1 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/dist/cli.js +74992 -0
- package/dist/cli.js.map +1 -0
- package/package.json +26 -5
- package/src/cli.tsx +141 -0
- package/src/commands/create-dashboard.tsx +455 -0
- package/src/commands/create-mobile.tsx +555 -0
- package/src/commands/create.tsx +1119 -0
- package/src/commands/generate-migration.mjs +17 -66
- package/src/commands/generate-migration.tsx +46 -0
- package/src/commands/generate-schemas.mjs +2 -2
- package/src/commands/generate-schemas.tsx +36 -0
- package/src/dashboard-features/ai/index.ts +102 -0
- package/src/dashboard-features/analytics/index.ts +31 -0
- package/src/dashboard-features/billing/index.ts +349 -0
- package/src/dashboard-features/content-type/index.ts +64 -0
- package/src/dashboard-features/email-templates/index.ts +17 -0
- package/src/dashboard-features/emailer/index.ts +27 -0
- package/src/dashboard-features/index.ts +28 -0
- package/src/dashboard-features/keybindings/index.ts +52 -0
- package/src/dashboard-features/manager.ts +349 -0
- package/src/dashboard-features/monitoring/index.ts +16 -0
- package/src/dashboard-features/notification/index.ts +40 -0
- package/src/dashboard-features/onboarding/index.ts +65 -0
- package/src/dashboard-features/organization/index.ts +38 -0
- package/src/dashboard-features/types.ts +41 -0
- package/src/mobile-features/index.ts +12 -0
- package/src/mobile-features/manager.ts +1 -0
- package/src/mobile-features/notification/index.ts +41 -0
- package/src/mobile-features/onboarding/index.ts +35 -0
- package/src/mobile-features/organization/index.ts +38 -0
- package/src/mobile-features/types.ts +1 -0
- package/src/shims/signal-exit.js +32 -0
- package/src/ui/app.tsx +68 -0
- package/src/ui/multi-select.tsx +106 -0
- package/src/utils/ast.ts +422 -0
- package/src/utils/env-template.ts +635 -0
- package/tests/test-cli-features.sh +81 -0
- package/tests/test-cli-mobile.sh +65 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +21 -0
- 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;
|