umpordez 1.0.3 → 1.2.1
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/.claude/settings.local.json +33 -1
- package/README.md +174 -52
- package/cli.mjs +75 -0
- package/create.mjs +376 -50
- package/icons.mjs +257 -0
- package/package.json +4 -3
- package/template/.claude/settings.json +49 -0
- package/template/AGENTS.md +80 -1
- package/template/CLAUDE.md +162 -108
- package/template/README.md +153 -85
- package/template/dev.sh +62 -25
- package/template/doc/index.html +638 -0
- package/template/install.sh +76 -19
- package/template/mobile/{{PROJECT_SLUG}}/App.tsx +88 -0
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +175 -0
- package/template/mobile/{{PROJECT_SLUG}}/README.md +149 -0
- package/template/mobile/{{PROJECT_SLUG}}/app.json +81 -0
- package/template/mobile/{{PROJECT_SLUG}}/assets/README.md +32 -0
- package/template/mobile/{{PROJECT_SLUG}}/babel.config.js +10 -0
- package/template/mobile/{{PROJECT_SLUG}}/eas.json +36 -0
- package/template/mobile/{{PROJECT_SLUG}}/eslint.config.js +18 -0
- package/template/mobile/{{PROJECT_SLUG}}/index.ts +74 -0
- package/template/mobile/{{PROJECT_SLUG}}/metro.config.js +5 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/README.md +106 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationReceiver.kt +65 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationsModule.kt +219 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationsPackage.kt +27 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-android/HelloModule.kt +58 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-android/HelloPackage.kt +28 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-ios/HelloModule.m +25 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-ios/HelloModule.swift +61 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/live-activity/AppLiveActivityAttributes.swift +25 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/live-activity/README.md +73 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/ios/AppNotificationsModule.m +28 -0
- package/template/mobile/{{PROJECT_SLUG}}/native/ios/AppNotificationsModule.swift +168 -0
- package/template/mobile/{{PROJECT_SLUG}}/package.json +60 -0
- package/template/mobile/{{PROJECT_SLUG}}/plugins/withAndroidHealthConnect.js +79 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +226 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/components/LanguagePicker.tsx +224 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/components/PremiumGate.tsx +123 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/components/ToastConfig.tsx +100 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +60 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/contexts/AuthContext.tsx +594 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/contexts/ThemeContext.tsx +67 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/i18n/index.ts +88 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/i18n/locales/en.ts +274 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/i18n/locales/pt-BR.ts +458 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +530 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/ForgotPassword.tsx +219 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/Login.tsx +274 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/Signup.tsx +291 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Action.tsx +113 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Explore.tsx +137 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Home.tsx +127 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Library.tsx +208 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Profile.tsx +440 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/FirstAction.tsx +168 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/Onboarding.tsx +297 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/Welcome.tsx +227 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/routes/index.tsx +410 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/routes/types.ts +31 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/api.ts +72 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/backup/export.ts +79 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/backup/index.ts +131 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/assetCache.ts +74 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/index.ts +65 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/signing.ts +44 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/content/sync.ts +147 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/db/index.ts +157 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/db/migrations.ts +84 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/db/prefs.ts +84 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/fetch-api.ts +90 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +238 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/iap/index.ts +409 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/services/notifications/index.ts +129 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/theme/colors.ts +61 -0
- package/template/mobile/{{PROJECT_SLUG}}/tsconfig.json +18 -0
- package/template/scripts/README.md +37 -0
- package/template/scripts/bump-version.sh +105 -0
- package/template/scripts/extract-play-key.js +64 -0
- package/template/scripts/generate-icons.sh +50 -0
- package/template/scripts/install-native.sh +169 -0
- package/template/scripts/prebuild.sh +28 -0
- package/template/scripts/release-android.sh +79 -0
- package/template/scripts/run-ios.sh +89 -0
- package/template/server/.env.sample +26 -1
- package/template/server/CLAUDE.md +135 -0
- package/template/server/apps/api/app.ts +28 -4
- package/template/server/apps/api/routes/account.ts +2 -2
- package/template/server/apps/api/routes/admin.ts +2 -2
- package/template/server/apps/api/routes/auth.ts +266 -6
- package/template/server/apps/api/routes/backup.ts +134 -0
- package/template/server/apps/api/routes/content.ts +142 -0
- package/template/server/apps/api/routes/files.ts +6 -2
- package/template/server/apps/api/routes/health.ts +55 -0
- package/template/server/apps/api/routes/iap.ts +159 -0
- package/template/server/apps/api/routes/index.ts +11 -4
- package/template/server/apps/shared/middlewares/demand-account-access.ts +9 -1
- package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +25 -4
- package/template/server/apps/site-api/app.ts +19 -5
- package/template/server/content/catalog.json +17 -0
- package/template/server/core/content.ts +69 -0
- package/template/server/core/email.ts +3 -5
- package/template/server/core/iap-verify.ts +66 -0
- package/template/server/core/knexfile.ts +6 -6
- package/template/server/core/logger.ts +34 -12
- package/template/server/core/models/auth.ts +151 -1
- package/template/server/core/models/user.ts +72 -4
- package/template/server/core/oauth.ts +277 -0
- package/template/server/core/s3.ts +78 -1
- package/template/server/dirname.ts +14 -0
- package/template/server/knex.sh +20 -2
- package/template/server/migrations/20260208000003_add-mobile-auth.ts +66 -0
- package/template/server/package.json +7 -5
- package/template/server/tests/core/content.test.ts +19 -0
- package/template/server/tests/core/context.test.ts +12 -0
- package/template/server/tests/core/db.test.ts +10 -0
- package/template/server/tests/core/oauth.test.ts +19 -0
- package/template/server/tests/test.sh +10 -0
- package/template/ui/admin/CLAUDE.md +101 -0
- package/template/ui/admin/src/lib/fetch-api.ts +19 -7
- package/template/ui/site/.env.sample +5 -0
- package/template/ui/site/CLAUDE.md +102 -0
- package/template/ui/site/app.ts +73 -18
- package/template/ui/site/i18n/index.ts +167 -0
- package/template/ui/site/i18n/locales/en.ts +119 -0
- package/template/ui/site/i18n/locales/pt-BR.ts +182 -0
- package/template/ui/site/package.json +1 -0
- package/template/ui/site/views/pages/home.ejs +60 -118
- package/template/ui/site/views/pages/not-found.ejs +22 -0
- package/template/ui/site/views/partials/footer.ejs +10 -10
- package/template/ui/site/views/partials/header.ejs +52 -22
- package/template-variables.mjs +59 -0
- package/update.mjs +555 -0
package/create.mjs
CHANGED
|
@@ -5,6 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
import { execSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { buildReplacements } from './template-variables.mjs';
|
|
8
|
+
import { generateIcons } from './icons.mjs';
|
|
9
|
+
import { writeInitialManifest } from './update.mjs';
|
|
8
10
|
|
|
9
11
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
12
|
const TEMPLATE_DIR = path.join(__dirname, 'template');
|
|
@@ -22,11 +24,64 @@ const BINARY_EXTENSIONS = new Set([
|
|
|
22
24
|
'.pdf', '.mp3', '.mp4', '.webm', '.ogg',
|
|
23
25
|
]);
|
|
24
26
|
|
|
27
|
+
// Presets describe which top-level template directories ship in a new
|
|
28
|
+
// project. The CLI scrubs the rest after copying — keeps the template
|
|
29
|
+
// structure unconditional and the create-time logic dead simple.
|
|
30
|
+
const PRESETS = {
|
|
31
|
+
everything: {
|
|
32
|
+
label: 'Everything (server + admin UI + site + mobile)',
|
|
33
|
+
includes: ['server', 'ui/admin', 'ui/site', 'mobile', 'misc',
|
|
34
|
+
'scripts', 'doc'],
|
|
35
|
+
wantsMobile: true,
|
|
36
|
+
wantsServer: true,
|
|
37
|
+
wantsAdminUi: true,
|
|
38
|
+
wantsSite: true,
|
|
39
|
+
},
|
|
40
|
+
'web-only': {
|
|
41
|
+
label: 'Web only (server + admin UI + site, no mobile)',
|
|
42
|
+
includes: ['server', 'ui/admin', 'ui/site', 'misc', 'doc'],
|
|
43
|
+
wantsMobile: false,
|
|
44
|
+
wantsServer: true,
|
|
45
|
+
wantsAdminUi: true,
|
|
46
|
+
wantsSite: true,
|
|
47
|
+
},
|
|
48
|
+
'mobile-and-server': {
|
|
49
|
+
label: 'Mobile + server only (no admin UI, no site)',
|
|
50
|
+
includes: ['server', 'mobile', 'misc', 'scripts', 'doc'],
|
|
51
|
+
wantsMobile: true,
|
|
52
|
+
wantsServer: true,
|
|
53
|
+
wantsAdminUi: false,
|
|
54
|
+
wantsSite: false,
|
|
55
|
+
},
|
|
56
|
+
'just-mobile': {
|
|
57
|
+
label: 'Just mobile (assumes external API)',
|
|
58
|
+
includes: ['mobile', 'scripts', 'doc'],
|
|
59
|
+
wantsMobile: true,
|
|
60
|
+
wantsServer: false,
|
|
61
|
+
wantsAdminUi: false,
|
|
62
|
+
wantsSite: false,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const ROOT_FILES_BY_PRESET = {
|
|
67
|
+
everything: ['CLAUDE.md', 'AGENTS.md', 'README.md',
|
|
68
|
+
'architecture.md', 'code.md',
|
|
69
|
+
'install.sh', 'seed.sh', 'dev.sh'],
|
|
70
|
+
'web-only': ['CLAUDE.md', 'AGENTS.md', 'README.md',
|
|
71
|
+
'architecture.md', 'code.md',
|
|
72
|
+
'install.sh', 'seed.sh', 'dev.sh'],
|
|
73
|
+
'mobile-and-server': ['CLAUDE.md', 'AGENTS.md', 'README.md',
|
|
74
|
+
'architecture.md', 'code.md',
|
|
75
|
+
'install.sh', 'seed.sh', 'dev.sh'],
|
|
76
|
+
'just-mobile': ['CLAUDE.md', 'README.md', 'code.md',
|
|
77
|
+
'install.sh', 'dev.sh'],
|
|
78
|
+
};
|
|
79
|
+
|
|
25
80
|
function slugify(text) {
|
|
26
81
|
return text
|
|
27
82
|
.toLowerCase()
|
|
28
83
|
.normalize('NFD')
|
|
29
|
-
.replace(/[
|
|
84
|
+
.replace(/[̀-ͯ]/g, '')
|
|
30
85
|
.replace(/[^a-z0-9]+/g, '-')
|
|
31
86
|
.replace(/^-|-$/g, '');
|
|
32
87
|
}
|
|
@@ -35,11 +90,30 @@ function toDbName(slug) {
|
|
|
35
90
|
return slug.replace(/-/g, '_');
|
|
36
91
|
}
|
|
37
92
|
|
|
93
|
+
function toReverseDomain(domain) {
|
|
94
|
+
// "minhaempresa.com.br" → "br.com.minhaempresa"
|
|
95
|
+
const parts = domain.toLowerCase().split('.').filter(Boolean);
|
|
96
|
+
return parts.reverse().join('.');
|
|
97
|
+
}
|
|
98
|
+
|
|
38
99
|
async function promptProjectDetails() {
|
|
39
100
|
console.log(green.bold(' Project Setup'));
|
|
40
|
-
console.log(chalk.hex('#2c2c2c')(' ' + '
|
|
101
|
+
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
41
102
|
console.log('');
|
|
42
103
|
|
|
104
|
+
const { preset } = await inquirer.prompt([{
|
|
105
|
+
type: 'list',
|
|
106
|
+
name: 'preset',
|
|
107
|
+
message: 'What do you want to generate?',
|
|
108
|
+
choices: Object.entries(PRESETS).map(([value, def]) => ({
|
|
109
|
+
name: def.label,
|
|
110
|
+
value,
|
|
111
|
+
})),
|
|
112
|
+
default: 'everything',
|
|
113
|
+
}]);
|
|
114
|
+
|
|
115
|
+
const presetDef = PRESETS[preset];
|
|
116
|
+
|
|
43
117
|
const { name } = await inquirer.prompt([{
|
|
44
118
|
type: 'input',
|
|
45
119
|
name: 'name',
|
|
@@ -50,7 +124,7 @@ async function promptProjectDetails() {
|
|
|
50
124
|
const suggestedSlug = slugify(name);
|
|
51
125
|
const suggestedDb = toDbName(suggestedSlug);
|
|
52
126
|
|
|
53
|
-
const
|
|
127
|
+
const baseQuestions = [
|
|
54
128
|
{
|
|
55
129
|
type: 'input',
|
|
56
130
|
name: 'slug',
|
|
@@ -72,6 +146,9 @@ async function promptProjectDetails() {
|
|
|
72
146
|
message: 'Domain (e.g. "minhaempresa.com.br"):',
|
|
73
147
|
validate: (v) => v.trim() ? true : 'Domain is required',
|
|
74
148
|
},
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
const serverQuestions = !presetDef.wantsServer ? [] : [
|
|
75
152
|
{
|
|
76
153
|
type: 'input',
|
|
77
154
|
name: 'dbName',
|
|
@@ -81,12 +158,6 @@ async function promptProjectDetails() {
|
|
|
81
158
|
? true
|
|
82
159
|
: 'Database name must be lowercase with underscores',
|
|
83
160
|
},
|
|
84
|
-
{
|
|
85
|
-
type: 'input',
|
|
86
|
-
name: 'outputDir',
|
|
87
|
-
message: 'Output directory:',
|
|
88
|
-
default: `./${suggestedSlug}`,
|
|
89
|
-
},
|
|
90
161
|
{
|
|
91
162
|
type: 'input',
|
|
92
163
|
name: 'adminEmail',
|
|
@@ -105,36 +176,43 @@ async function promptProjectDetails() {
|
|
|
105
176
|
name: 'apiPort',
|
|
106
177
|
message: 'Admin API port:',
|
|
107
178
|
default: '4000',
|
|
108
|
-
validate:
|
|
109
|
-
? true
|
|
110
|
-
: 'Must be a valid port number',
|
|
179
|
+
validate: validatePort,
|
|
111
180
|
},
|
|
112
181
|
{
|
|
113
182
|
type: 'input',
|
|
114
183
|
name: 'siteApiPort',
|
|
115
184
|
message: 'Site API port:',
|
|
116
185
|
default: '4001',
|
|
117
|
-
validate:
|
|
118
|
-
? true
|
|
119
|
-
: 'Must be a valid port number',
|
|
186
|
+
validate: validatePort,
|
|
120
187
|
},
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
const webUiQuestions = !presetDef.wantsAdminUi ? [] : [
|
|
121
191
|
{
|
|
122
192
|
type: 'input',
|
|
123
193
|
name: 'adminUiPort',
|
|
124
194
|
message: 'Admin panel port:',
|
|
125
195
|
default: '5173',
|
|
126
|
-
validate:
|
|
127
|
-
? true
|
|
128
|
-
: 'Must be a valid port number',
|
|
196
|
+
validate: validatePort,
|
|
129
197
|
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
const siteQuestions = !presetDef.wantsSite ? [] : [
|
|
130
201
|
{
|
|
131
202
|
type: 'input',
|
|
132
203
|
name: 'sitePort',
|
|
133
204
|
message: 'Public site port:',
|
|
134
205
|
default: '3000',
|
|
135
|
-
validate:
|
|
136
|
-
|
|
137
|
-
|
|
206
|
+
validate: validatePort,
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
|
|
210
|
+
const styleQuestions = [
|
|
211
|
+
{
|
|
212
|
+
type: 'input',
|
|
213
|
+
name: 'outputDir',
|
|
214
|
+
message: 'Output directory:',
|
|
215
|
+
default: `./${suggestedSlug}`,
|
|
138
216
|
},
|
|
139
217
|
{
|
|
140
218
|
type: 'input',
|
|
@@ -157,36 +235,114 @@ async function promptProjectDetails() {
|
|
|
157
235
|
],
|
|
158
236
|
default: 'light',
|
|
159
237
|
},
|
|
238
|
+
];
|
|
239
|
+
|
|
240
|
+
const mobileQuestions = !presetDef.wantsMobile ? [] : [
|
|
241
|
+
{
|
|
242
|
+
type: 'input',
|
|
243
|
+
name: 'iosBundleId',
|
|
244
|
+
message: 'iOS bundle identifier:',
|
|
245
|
+
default: ({ domain }) => `${toReverseDomain(domain || '')}.app`,
|
|
246
|
+
validate: (v) => /^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$/i.test(v)
|
|
247
|
+
? true
|
|
248
|
+
: 'Must be reverse-DNS (e.g. com.brand.app)',
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
type: 'input',
|
|
252
|
+
name: 'androidPackage',
|
|
253
|
+
message: 'Android package:',
|
|
254
|
+
default: ({ iosBundleId }) =>
|
|
255
|
+
iosBundleId ? iosBundleId.replace(/\.app$/, '') : '',
|
|
256
|
+
validate: (v) => /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/i.test(v)
|
|
257
|
+
? true
|
|
258
|
+
: 'Must be reverse-DNS using underscores (e.g. br.com.brand)',
|
|
259
|
+
},
|
|
260
|
+
{
|
|
261
|
+
type: 'input',
|
|
262
|
+
name: 'mobileScheme',
|
|
263
|
+
message: 'Mobile URL scheme (deep links):',
|
|
264
|
+
default: ({ slug }) => slug,
|
|
265
|
+
validate: (v) => /^[a-z][a-z0-9-]*$/.test(v)
|
|
266
|
+
? true
|
|
267
|
+
: 'Lowercase alphanumeric, dashes allowed',
|
|
268
|
+
},
|
|
269
|
+
];
|
|
270
|
+
|
|
271
|
+
const answers = await inquirer.prompt([
|
|
272
|
+
...baseQuestions,
|
|
273
|
+
...serverQuestions,
|
|
274
|
+
...webUiQuestions,
|
|
275
|
+
...siteQuestions,
|
|
276
|
+
...styleQuestions,
|
|
277
|
+
...mobileQuestions,
|
|
160
278
|
]);
|
|
161
279
|
|
|
162
|
-
return {
|
|
280
|
+
return {
|
|
281
|
+
preset,
|
|
282
|
+
presetDef,
|
|
283
|
+
name: name.trim(),
|
|
284
|
+
// Defaults for the parts the user didn't see — they're still
|
|
285
|
+
// referenced by replacements (some unconditionally), so we fill
|
|
286
|
+
// safe placeholders.
|
|
287
|
+
dbName: answers.dbName ?? suggestedDb,
|
|
288
|
+
adminEmail: answers.adminEmail ?? 'admin@localhost.com',
|
|
289
|
+
adminPassword: answers.adminPassword ?? 'admin123',
|
|
290
|
+
apiPort: answers.apiPort ?? '4000',
|
|
291
|
+
siteApiPort: answers.siteApiPort ?? '4001',
|
|
292
|
+
adminUiPort: answers.adminUiPort ?? '5173',
|
|
293
|
+
sitePort: answers.sitePort ?? '3000',
|
|
294
|
+
iosBundleId: answers.iosBundleId
|
|
295
|
+
?? `${toReverseDomain(answers.domain)}.app`,
|
|
296
|
+
androidPackage: answers.androidPackage
|
|
297
|
+
?? toReverseDomain(answers.domain),
|
|
298
|
+
mobileScheme: answers.mobileScheme ?? answers.slug,
|
|
299
|
+
...answers,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validatePort(v) {
|
|
304
|
+
return /^\d+$/.test(v) && +v > 0 && +v < 65536
|
|
305
|
+
? true
|
|
306
|
+
: 'Must be a valid port number';
|
|
163
307
|
}
|
|
164
308
|
|
|
165
309
|
function showSummary(details) {
|
|
310
|
+
const { presetDef } = details;
|
|
166
311
|
console.log('');
|
|
167
312
|
console.log(green.bold(' Project Summary'));
|
|
168
|
-
console.log(chalk.hex('#2c2c2c')(' ' + '
|
|
313
|
+
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
169
314
|
console.log('');
|
|
315
|
+
console.log(` ${chalk.bold('Preset:')} ${cyan(presetDef.label)}`);
|
|
170
316
|
console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
|
|
171
317
|
console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
|
|
172
318
|
console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
|
|
173
319
|
console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
|
|
174
|
-
|
|
320
|
+
if (presetDef.wantsServer) {
|
|
321
|
+
console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
|
|
322
|
+
console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
|
|
323
|
+
}
|
|
175
324
|
console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
325
|
+
const ports = [];
|
|
326
|
+
if (presetDef.wantsServer) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
|
|
327
|
+
if (presetDef.wantsAdminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
|
|
328
|
+
if (presetDef.wantsSite) ports.push(`Site ${cyan(details.sitePort)}`);
|
|
329
|
+
if (ports.length) {
|
|
330
|
+
console.log(` ${chalk.bold('Ports:')} ${ports.join(' | ')}`);
|
|
331
|
+
}
|
|
332
|
+
console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('██')} ${details.primaryColor}`);
|
|
179
333
|
console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
|
|
334
|
+
if (presetDef.wantsMobile) {
|
|
335
|
+
console.log(` ${chalk.bold('iOS:')} ${details.iosBundleId}`);
|
|
336
|
+
console.log(` ${chalk.bold('Android:')} ${details.androidPackage}`);
|
|
337
|
+
console.log(` ${chalk.bold('Scheme:')} ${details.mobileScheme}://`);
|
|
338
|
+
}
|
|
180
339
|
console.log('');
|
|
181
|
-
console.log(dim('
|
|
182
|
-
console.log(dim(` ${green('>')} Admin API (Express +
|
|
183
|
-
console.log(dim(` ${green('>')}
|
|
184
|
-
console.log(dim(` ${green('>')}
|
|
185
|
-
console.log(dim(` ${green('>')}
|
|
186
|
-
console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
|
|
187
|
-
console.log(dim(` ${green('>')} S3 file uploads`));
|
|
188
|
-
console.log(dim(` ${green('>')} Console task runner`));
|
|
189
|
-
console.log(dim(` ${green('>')} Production build system`));
|
|
340
|
+
console.log(dim(' Generates:'));
|
|
341
|
+
if (presetDef.wantsServer) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
|
|
342
|
+
if (presetDef.wantsAdminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
|
|
343
|
+
if (presetDef.wantsSite) console.log(dim(` ${green('>')} Public site (EJS + Tailwind)`));
|
|
344
|
+
if (presetDef.wantsMobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
|
|
345
|
+
if (presetDef.wantsServer) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
|
|
190
346
|
console.log('');
|
|
191
347
|
}
|
|
192
348
|
|
|
@@ -218,6 +374,76 @@ function replacePlaceholders(content, replacements) {
|
|
|
218
374
|
return result;
|
|
219
375
|
}
|
|
220
376
|
|
|
377
|
+
// Copies the parts of the template the preset asks for. We always
|
|
378
|
+
// recursively copy the whole template tree first then prune what the
|
|
379
|
+
// preset doesn't want — simpler than walking with a filter function and
|
|
380
|
+
// keeps the template layout flat in source.
|
|
381
|
+
// Top-level directories that ship with every preset regardless of
|
|
382
|
+
// what the user picked. Project-level config (Claude rules, editor
|
|
383
|
+
// config, git ignore) doesn't belong to any single sub-app.
|
|
384
|
+
const ALWAYS_KEEP = new Set(['.claude']);
|
|
385
|
+
|
|
386
|
+
async function copyTemplate(outputDir, preset) {
|
|
387
|
+
const presetDef = PRESETS[preset];
|
|
388
|
+
await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
|
|
389
|
+
|
|
390
|
+
// Top-level dirs to keep
|
|
391
|
+
const keep = new Set([...presetDef.includes, ...ALWAYS_KEEP]);
|
|
392
|
+
const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
|
|
393
|
+
|
|
394
|
+
for (const entry of entries) {
|
|
395
|
+
if (!entry.isDirectory()) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
// Special-case ui/: keep only the requested children
|
|
399
|
+
if (entry.name === 'ui') {
|
|
400
|
+
const wantedChildren = presetDef.includes
|
|
401
|
+
.filter(p => p.startsWith('ui/'))
|
|
402
|
+
.map(p => p.slice('ui/'.length));
|
|
403
|
+
if (wantedChildren.length === 0) {
|
|
404
|
+
await fs.promises.rm(
|
|
405
|
+
path.join(outputDir, 'ui'),
|
|
406
|
+
{ recursive: true, force: true }
|
|
407
|
+
);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const uiEntries = await fs.promises.readdir(
|
|
411
|
+
path.join(outputDir, 'ui'),
|
|
412
|
+
{ withFileTypes: true }
|
|
413
|
+
);
|
|
414
|
+
for (const uiEntry of uiEntries) {
|
|
415
|
+
if (!wantedChildren.includes(uiEntry.name)) {
|
|
416
|
+
await fs.promises.rm(
|
|
417
|
+
path.join(outputDir, 'ui', uiEntry.name),
|
|
418
|
+
{ recursive: true, force: true }
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
if (!keep.has(entry.name)) {
|
|
425
|
+
await fs.promises.rm(
|
|
426
|
+
path.join(outputDir, entry.name),
|
|
427
|
+
{ recursive: true, force: true }
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Top-level files: drop dev.sh / seed.sh etc. when not relevant
|
|
433
|
+
const allowedFiles = new Set(ROOT_FILES_BY_PRESET[preset] ?? []);
|
|
434
|
+
const rootEntries = await fs.promises.readdir(outputDir, {
|
|
435
|
+
withFileTypes: true
|
|
436
|
+
});
|
|
437
|
+
for (const entry of rootEntries) {
|
|
438
|
+
if (entry.isDirectory()) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (!allowedFiles.has(entry.name)) {
|
|
442
|
+
await fs.promises.rm(path.join(outputDir, entry.name));
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
221
447
|
export async function createProject() {
|
|
222
448
|
const details = await promptProjectDetails();
|
|
223
449
|
|
|
@@ -252,12 +478,17 @@ export async function createProject() {
|
|
|
252
478
|
}
|
|
253
479
|
|
|
254
480
|
const replacements = buildReplacements(details);
|
|
481
|
+
// Lift the random secrets into details so the manifest persists
|
|
482
|
+
// them — otherwise `umpordez update` would mint fresh ones and
|
|
483
|
+
// overwrite working .env values on every run.
|
|
484
|
+
details.contentSecret = replacements['{{CONTENT_SECRET}}'];
|
|
485
|
+
details.jwtTokenSecret = replacements['{{JWT_TOKEN_SECRET}}'];
|
|
255
486
|
|
|
256
487
|
console.log('');
|
|
257
488
|
|
|
258
|
-
// [1/5] Copy template
|
|
489
|
+
// [1/5] Copy template (preset-aware pruning)
|
|
259
490
|
console.log(green('[1/5]') + ' Copying template files...');
|
|
260
|
-
await
|
|
491
|
+
await copyTemplate(outputDir, details.preset);
|
|
261
492
|
|
|
262
493
|
// [2/5] Replace placeholders
|
|
263
494
|
const files = await getAllFiles(outputDir);
|
|
@@ -279,7 +510,7 @@ export async function createProject() {
|
|
|
279
510
|
|
|
280
511
|
console.log(green('[2/5]') + ` Replacing placeholders... ${dim(`(${replacedCount} files updated)`)}`);
|
|
281
512
|
|
|
282
|
-
// [3/5] Rename files
|
|
513
|
+
// [3/5] Rename files (path-name placeholders)
|
|
283
514
|
const allEntries = await getAllFiles(outputDir);
|
|
284
515
|
for (const filePath of allEntries) {
|
|
285
516
|
const dir = path.dirname(filePath);
|
|
@@ -292,15 +523,51 @@ export async function createProject() {
|
|
|
292
523
|
}
|
|
293
524
|
}
|
|
294
525
|
|
|
526
|
+
// Path-segment renames (e.g. mobile/{{PROJECT_SLUG}}/)
|
|
527
|
+
await renameDirs(outputDir, details.slug);
|
|
528
|
+
|
|
295
529
|
console.log(green('[3/5]') + ' Renaming template files...');
|
|
296
530
|
|
|
297
|
-
// [4/
|
|
531
|
+
// [4/6] Generate branded icons (mobile only). The template ships
|
|
532
|
+
// NO icon PNGs — they're produced fresh per project from the
|
|
533
|
+
// primary color + first letter of the project name. A failure
|
|
534
|
+
// here is fatal (without these files, expo prebuild + the app
|
|
535
|
+
// splash both break), so we surface the error instead of swallowing.
|
|
536
|
+
if (details.presetDef.wantsMobile) {
|
|
537
|
+
const mobileAssets = path.join(
|
|
538
|
+
outputDir, 'mobile', details.slug, 'assets'
|
|
539
|
+
);
|
|
540
|
+
await generateIcons({
|
|
541
|
+
outDir: mobileAssets,
|
|
542
|
+
primaryColor: details.primaryColor,
|
|
543
|
+
letter: details.name.charAt(0).toUpperCase()
|
|
544
|
+
});
|
|
545
|
+
console.log(
|
|
546
|
+
green('[4/6]')
|
|
547
|
+
+ ' Generating branded icons...'
|
|
548
|
+
+ dim(' (icon, splash, favicon, store)')
|
|
549
|
+
);
|
|
550
|
+
} else {
|
|
551
|
+
console.log(green('[4/6]') + dim(' Skipping icons (no mobile)'));
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// [5/6] Make scripts executable
|
|
298
555
|
const scripts = [
|
|
299
556
|
'dev.sh',
|
|
300
557
|
'install.sh',
|
|
301
558
|
'seed.sh',
|
|
302
559
|
'server/knex.sh',
|
|
303
560
|
];
|
|
561
|
+
if (details.presetDef.wantsMobile) {
|
|
562
|
+
scripts.push(
|
|
563
|
+
'scripts/bump-version.sh',
|
|
564
|
+
'scripts/prebuild.sh',
|
|
565
|
+
'scripts/run-ios.sh',
|
|
566
|
+
'scripts/release-android.sh',
|
|
567
|
+
'scripts/generate-icons.sh',
|
|
568
|
+
'scripts/install-native.sh'
|
|
569
|
+
);
|
|
570
|
+
}
|
|
304
571
|
|
|
305
572
|
for (const script of scripts) {
|
|
306
573
|
const scriptPath = path.join(outputDir, script);
|
|
@@ -309,25 +576,84 @@ export async function createProject() {
|
|
|
309
576
|
}
|
|
310
577
|
}
|
|
311
578
|
|
|
312
|
-
console.log(green('[
|
|
579
|
+
console.log(green('[5/6]') + ' Making scripts executable...');
|
|
580
|
+
|
|
581
|
+
// [6/6] Manifest + git init
|
|
582
|
+
// Manifest captures sha256 of every file as it was generated +
|
|
583
|
+
// the user's prompt answers. `umpordez update <path>` reads it
|
|
584
|
+
// to know which files are user-edited vs untouched.
|
|
585
|
+
await writeInitialManifest({
|
|
586
|
+
projectDir: outputDir,
|
|
587
|
+
preset: details.preset,
|
|
588
|
+
details
|
|
589
|
+
});
|
|
313
590
|
|
|
314
|
-
// [5/5] Git init
|
|
315
591
|
execSync('git init', { cwd: outputDir, stdio: 'ignore' });
|
|
316
|
-
console.log(green('[
|
|
592
|
+
console.log(green('[6/6]') + ' Manifest + git init...');
|
|
317
593
|
|
|
318
594
|
// Done!
|
|
319
595
|
console.log('');
|
|
320
|
-
console.log(green.bold(`
|
|
596
|
+
console.log(green.bold(` ✓ "${details.name}" created successfully!`));
|
|
321
597
|
console.log('');
|
|
598
|
+
showNextSteps(details);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Recursively rename any directory whose basename contains
|
|
602
|
+
// {{PROJECT_SLUG}}. Walks bottom-up so renames don't invalidate the
|
|
603
|
+
// queue.
|
|
604
|
+
async function renameDirs(root, slug) {
|
|
605
|
+
const stack = [];
|
|
606
|
+
async function walk(dir) {
|
|
607
|
+
const entries = await fs.promises.readdir(dir, {
|
|
608
|
+
withFileTypes: true,
|
|
609
|
+
});
|
|
610
|
+
for (const entry of entries) {
|
|
611
|
+
if (!entry.isDirectory()) {
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
const fullPath = path.join(dir, entry.name);
|
|
615
|
+
await walk(fullPath);
|
|
616
|
+
stack.push(fullPath);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
await walk(root);
|
|
620
|
+
for (const dirPath of stack) {
|
|
621
|
+
const basename = path.basename(dirPath);
|
|
622
|
+
if (basename.includes('{{PROJECT_SLUG}}')) {
|
|
623
|
+
const newPath = path.join(
|
|
624
|
+
path.dirname(dirPath),
|
|
625
|
+
basename.replaceAll('{{PROJECT_SLUG}}', slug)
|
|
626
|
+
);
|
|
627
|
+
await fs.promises.rename(dirPath, newPath);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function showNextSteps(details) {
|
|
633
|
+
const { presetDef } = details;
|
|
322
634
|
console.log(chalk.bold(' Next steps:'));
|
|
323
635
|
console.log(` ${green('1.')} cd ${details.outputDir}`);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
636
|
+
if (presetDef.wantsServer) {
|
|
637
|
+
console.log(` ${green('2.')} ./install.sh`);
|
|
638
|
+
console.log(` ${green('3.')} ./seed.sh`);
|
|
639
|
+
console.log(` ${green('4.')} ./dev.sh`);
|
|
640
|
+
} else {
|
|
641
|
+
console.log(` ${green('2.')} cd mobile/${details.slug} && npm install`);
|
|
642
|
+
console.log(` ${green('3.')} npm run start`);
|
|
643
|
+
}
|
|
327
644
|
console.log('');
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
645
|
+
if (presetDef.wantsAdminUi) {
|
|
646
|
+
console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
|
|
647
|
+
}
|
|
648
|
+
if (presetDef.wantsServer) {
|
|
649
|
+
console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
|
|
650
|
+
}
|
|
651
|
+
if (presetDef.wantsSite) {
|
|
652
|
+
console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
|
|
653
|
+
}
|
|
654
|
+
if (presetDef.wantsMobile) {
|
|
655
|
+
console.log(dim(` Mobile: cd mobile/${details.slug} && npm run ios | android | web`));
|
|
656
|
+
}
|
|
331
657
|
console.log('');
|
|
332
658
|
console.log(dim(' Happy hacking! - ') + cyan('youtube.com/ligeiro'));
|
|
333
659
|
console.log('');
|