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.
Files changed (134) hide show
  1. package/.claude/settings.local.json +33 -1
  2. package/README.md +174 -52
  3. package/cli.mjs +75 -0
  4. package/create.mjs +376 -50
  5. package/icons.mjs +257 -0
  6. package/package.json +4 -3
  7. package/template/.claude/settings.json +49 -0
  8. package/template/AGENTS.md +80 -1
  9. package/template/CLAUDE.md +162 -108
  10. package/template/README.md +153 -85
  11. package/template/dev.sh +62 -25
  12. package/template/doc/index.html +638 -0
  13. package/template/install.sh +76 -19
  14. package/template/mobile/{{PROJECT_SLUG}}/App.tsx +88 -0
  15. package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +175 -0
  16. package/template/mobile/{{PROJECT_SLUG}}/README.md +149 -0
  17. package/template/mobile/{{PROJECT_SLUG}}/app.json +81 -0
  18. package/template/mobile/{{PROJECT_SLUG}}/assets/README.md +32 -0
  19. package/template/mobile/{{PROJECT_SLUG}}/babel.config.js +10 -0
  20. package/template/mobile/{{PROJECT_SLUG}}/eas.json +36 -0
  21. package/template/mobile/{{PROJECT_SLUG}}/eslint.config.js +18 -0
  22. package/template/mobile/{{PROJECT_SLUG}}/index.ts +74 -0
  23. package/template/mobile/{{PROJECT_SLUG}}/metro.config.js +5 -0
  24. package/template/mobile/{{PROJECT_SLUG}}/native/README.md +106 -0
  25. package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationReceiver.kt +65 -0
  26. package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationsModule.kt +219 -0
  27. package/template/mobile/{{PROJECT_SLUG}}/native/android/AppNotificationsPackage.kt +27 -0
  28. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-android/HelloModule.kt +58 -0
  29. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-android/HelloPackage.kt +28 -0
  30. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-ios/HelloModule.m +25 -0
  31. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/hello-ios/HelloModule.swift +61 -0
  32. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/live-activity/AppLiveActivityAttributes.swift +25 -0
  33. package/template/mobile/{{PROJECT_SLUG}}/native/cookbook/live-activity/README.md +73 -0
  34. package/template/mobile/{{PROJECT_SLUG}}/native/ios/AppNotificationsModule.m +28 -0
  35. package/template/mobile/{{PROJECT_SLUG}}/native/ios/AppNotificationsModule.swift +168 -0
  36. package/template/mobile/{{PROJECT_SLUG}}/package.json +60 -0
  37. package/template/mobile/{{PROJECT_SLUG}}/plugins/withAndroidHealthConnect.js +79 -0
  38. package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +226 -0
  39. package/template/mobile/{{PROJECT_SLUG}}/src/components/LanguagePicker.tsx +224 -0
  40. package/template/mobile/{{PROJECT_SLUG}}/src/components/PremiumGate.tsx +123 -0
  41. package/template/mobile/{{PROJECT_SLUG}}/src/components/ToastConfig.tsx +100 -0
  42. package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +60 -0
  43. package/template/mobile/{{PROJECT_SLUG}}/src/contexts/AuthContext.tsx +594 -0
  44. package/template/mobile/{{PROJECT_SLUG}}/src/contexts/ThemeContext.tsx +67 -0
  45. package/template/mobile/{{PROJECT_SLUG}}/src/i18n/index.ts +88 -0
  46. package/template/mobile/{{PROJECT_SLUG}}/src/i18n/locales/en.ts +274 -0
  47. package/template/mobile/{{PROJECT_SLUG}}/src/i18n/locales/pt-BR.ts +458 -0
  48. package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +530 -0
  49. package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/ForgotPassword.tsx +219 -0
  50. package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/Login.tsx +274 -0
  51. package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/Signup.tsx +291 -0
  52. package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Action.tsx +113 -0
  53. package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Explore.tsx +137 -0
  54. package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Home.tsx +127 -0
  55. package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Library.tsx +208 -0
  56. package/template/mobile/{{PROJECT_SLUG}}/src/pages/main/Profile.tsx +440 -0
  57. package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/FirstAction.tsx +168 -0
  58. package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/Onboarding.tsx +297 -0
  59. package/template/mobile/{{PROJECT_SLUG}}/src/pages/onboarding/Welcome.tsx +227 -0
  60. package/template/mobile/{{PROJECT_SLUG}}/src/routes/index.tsx +410 -0
  61. package/template/mobile/{{PROJECT_SLUG}}/src/routes/types.ts +31 -0
  62. package/template/mobile/{{PROJECT_SLUG}}/src/services/api.ts +72 -0
  63. package/template/mobile/{{PROJECT_SLUG}}/src/services/backup/export.ts +79 -0
  64. package/template/mobile/{{PROJECT_SLUG}}/src/services/backup/index.ts +131 -0
  65. package/template/mobile/{{PROJECT_SLUG}}/src/services/content/assetCache.ts +74 -0
  66. package/template/mobile/{{PROJECT_SLUG}}/src/services/content/index.ts +65 -0
  67. package/template/mobile/{{PROJECT_SLUG}}/src/services/content/signing.ts +44 -0
  68. package/template/mobile/{{PROJECT_SLUG}}/src/services/content/sync.ts +147 -0
  69. package/template/mobile/{{PROJECT_SLUG}}/src/services/db/index.ts +157 -0
  70. package/template/mobile/{{PROJECT_SLUG}}/src/services/db/migrations.ts +84 -0
  71. package/template/mobile/{{PROJECT_SLUG}}/src/services/db/prefs.ts +84 -0
  72. package/template/mobile/{{PROJECT_SLUG}}/src/services/fetch-api.ts +90 -0
  73. package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +238 -0
  74. package/template/mobile/{{PROJECT_SLUG}}/src/services/iap/index.ts +409 -0
  75. package/template/mobile/{{PROJECT_SLUG}}/src/services/notifications/index.ts +129 -0
  76. package/template/mobile/{{PROJECT_SLUG}}/src/theme/colors.ts +61 -0
  77. package/template/mobile/{{PROJECT_SLUG}}/tsconfig.json +18 -0
  78. package/template/scripts/README.md +37 -0
  79. package/template/scripts/bump-version.sh +105 -0
  80. package/template/scripts/extract-play-key.js +64 -0
  81. package/template/scripts/generate-icons.sh +50 -0
  82. package/template/scripts/install-native.sh +169 -0
  83. package/template/scripts/prebuild.sh +28 -0
  84. package/template/scripts/release-android.sh +79 -0
  85. package/template/scripts/run-ios.sh +89 -0
  86. package/template/server/.env.sample +26 -1
  87. package/template/server/CLAUDE.md +135 -0
  88. package/template/server/apps/api/app.ts +28 -4
  89. package/template/server/apps/api/routes/account.ts +2 -2
  90. package/template/server/apps/api/routes/admin.ts +2 -2
  91. package/template/server/apps/api/routes/auth.ts +266 -6
  92. package/template/server/apps/api/routes/backup.ts +134 -0
  93. package/template/server/apps/api/routes/content.ts +142 -0
  94. package/template/server/apps/api/routes/files.ts +6 -2
  95. package/template/server/apps/api/routes/health.ts +55 -0
  96. package/template/server/apps/api/routes/iap.ts +159 -0
  97. package/template/server/apps/api/routes/index.ts +11 -4
  98. package/template/server/apps/shared/middlewares/demand-account-access.ts +9 -1
  99. package/template/server/apps/shared/middlewares/try-set-user-by-token.ts +25 -4
  100. package/template/server/apps/site-api/app.ts +19 -5
  101. package/template/server/content/catalog.json +17 -0
  102. package/template/server/core/content.ts +69 -0
  103. package/template/server/core/email.ts +3 -5
  104. package/template/server/core/iap-verify.ts +66 -0
  105. package/template/server/core/knexfile.ts +6 -6
  106. package/template/server/core/logger.ts +34 -12
  107. package/template/server/core/models/auth.ts +151 -1
  108. package/template/server/core/models/user.ts +72 -4
  109. package/template/server/core/oauth.ts +277 -0
  110. package/template/server/core/s3.ts +78 -1
  111. package/template/server/dirname.ts +14 -0
  112. package/template/server/knex.sh +20 -2
  113. package/template/server/migrations/20260208000003_add-mobile-auth.ts +66 -0
  114. package/template/server/package.json +7 -5
  115. package/template/server/tests/core/content.test.ts +19 -0
  116. package/template/server/tests/core/context.test.ts +12 -0
  117. package/template/server/tests/core/db.test.ts +10 -0
  118. package/template/server/tests/core/oauth.test.ts +19 -0
  119. package/template/server/tests/test.sh +10 -0
  120. package/template/ui/admin/CLAUDE.md +101 -0
  121. package/template/ui/admin/src/lib/fetch-api.ts +19 -7
  122. package/template/ui/site/.env.sample +5 -0
  123. package/template/ui/site/CLAUDE.md +102 -0
  124. package/template/ui/site/app.ts +73 -18
  125. package/template/ui/site/i18n/index.ts +167 -0
  126. package/template/ui/site/i18n/locales/en.ts +119 -0
  127. package/template/ui/site/i18n/locales/pt-BR.ts +182 -0
  128. package/template/ui/site/package.json +1 -0
  129. package/template/ui/site/views/pages/home.ejs +60 -118
  130. package/template/ui/site/views/pages/not-found.ejs +22 -0
  131. package/template/ui/site/views/partials/footer.ejs +10 -10
  132. package/template/ui/site/views/partials/header.ejs +52 -22
  133. package/template-variables.mjs +59 -0
  134. 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(/[\u0300-\u036f]/g, '')
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')(' ' + '\u2500'.repeat(40)));
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 answers = await inquirer.prompt([
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: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
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: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
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: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
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: (v) => /^\d+$/.test(v) && +v > 0 && +v < 65536
136
- ? true
137
- : 'Must be a valid port number',
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 { name: name.trim(), ...answers };
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')(' ' + '\u2500'.repeat(40)));
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
- console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
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
- console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
177
- console.log(` ${chalk.bold('Ports:')} API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)} | Admin ${cyan(details.adminUiPort)} | Site ${cyan(details.sitePort)}`);
178
- console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('\u2588\u2588')} ${details.primaryColor}`);
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(' What gets generated:'));
182
- console.log(dim(` ${green('>')} Admin API (Express + TypeScript)`));
183
- console.log(dim(` ${green('>')} Site API (Express + TypeScript)`));
184
- console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
185
- console.log(dim(` ${green('>')} Public site (EJS + Tailwind)`));
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 fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
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/5] Make scripts executable
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('[4/5]') + ' Making scripts executable...');
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('[5/5]') + ' Initializing git repository...');
592
+ console.log(green('[6/6]') + ' Manifest + git init...');
317
593
 
318
594
  // Done!
319
595
  console.log('');
320
- console.log(green.bold(` \u2713 "${details.name}" created successfully!`));
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
- console.log(` ${green('2.')} ./install.sh`);
325
- console.log(` ${green('3.')} ./seed.sh`);
326
- console.log(` ${green('4.')} ./dev.sh`);
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
- console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
329
- console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
330
- console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
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('');