umpordez 1.3.2 → 1.5.0

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.
@@ -49,7 +49,15 @@
49
49
  "Bash(npm pack *)",
50
50
  "Read(//Users/ligeiro/dev/dropa/**)",
51
51
  "Read(//Users/ligeiro/dev/growerheleper-v2/app/**)",
52
- "Bash(git mv *)"
52
+ "Bash(git mv *)",
53
+ "Bash(npm view *)",
54
+ "Bash(tar -xzf expo-57.0.4.tgz package/bundledNativeModules.json package/package.json)",
55
+ "Bash(npx expo-doctor *)",
56
+ "Bash(pkill -f \"expo start --port 8090\")",
57
+ "Bash(git *)",
58
+ "Bash(npm init *)",
59
+ "Bash(awk 'NR>=383 && NR<=386 {print NR\": \"substr\\($0,1,120\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')",
60
+ "Bash(awk 'NR>=394 && NR<=410 {print NR\": \"substr\\($0,1,100\\)}' '/Users/ligeiro/dev/umpordez-cli/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx')"
53
61
  ]
54
62
  }
55
63
  }
package/cli.mjs CHANGED
@@ -41,7 +41,7 @@ function showHelp() {
41
41
  console.log(` umpordez --version ${dim('Show version')}`);
42
42
  console.log('');
43
43
  console.log(green.bold('What you get:'));
44
- console.log(` ${green('>')} Presets: everything / web-only / mobile+site / mobile+server / just-mobile`);
44
+ console.log(` ${green('>')} Pick components one-by-one: server API / admin webapp / site / mobile`);
45
45
  console.log(` ${green('>')} Multi-tenant auth (cookie-based JWT)`);
46
46
  console.log(` ${green('>')} Admin API + Site API (Express)`);
47
47
  console.log(` ${green('>')} React admin panel (Vite + shadcn/ui)`);
package/create.mjs CHANGED
@@ -7,7 +7,10 @@ import { fileURLToPath } from 'node:url';
7
7
  import {
8
8
  buildReplacements,
9
9
  APP_LANDING_SWAPS,
10
- SITE_VARIANT_DIR
10
+ SITE_VARIANT_DIR,
11
+ includesForComponents,
12
+ rootFilesForComponents,
13
+ presetNameForComponents
11
14
  } from './template-variables.mjs';
12
15
  import { generateIcons } from './icons.mjs';
13
16
  import { writeInitialManifest } from './update.mjs';
@@ -28,71 +31,12 @@ const BINARY_EXTENSIONS = new Set([
28
31
  '.pdf', '.mp3', '.mp4', '.webm', '.ogg',
29
32
  ]);
30
33
 
31
- // Presets describe which top-level template directories ship in a new
32
- // project. The CLI scrubs the rest after copying — keeps the template
33
- // structure unconditional and the create-time logic dead simple.
34
- const PRESETS = {
35
- everything: {
36
- label: 'Everything (server + admin UI + site + mobile)',
37
- includes: ['server', 'ui/admin', 'ui/site', 'mobile', 'misc',
38
- 'scripts', 'doc'],
39
- wantsMobile: true,
40
- wantsServer: true,
41
- wantsAdminUi: true,
42
- wantsSite: true,
43
- },
44
- 'web-only': {
45
- label: 'Web only (server + admin UI + site, no mobile)',
46
- includes: ['server', 'ui/admin', 'ui/site', 'misc', 'doc'],
47
- wantsMobile: false,
48
- wantsServer: true,
49
- wantsAdminUi: true,
50
- wantsSite: true,
51
- },
52
- 'mobile-and-site': {
53
- label: 'Mobile app + landing page (no server, no admin)',
54
- includes: ['mobile', 'ui/site', 'scripts', 'doc'],
55
- wantsMobile: true,
56
- wantsServer: false,
57
- wantsAdminUi: false,
58
- wantsSite: true,
59
- // Swaps the SaaS landing for the app-marketing landing
60
- // (App Store / Play buttons, pricing, download CTA).
61
- appLanding: true,
62
- },
63
- 'mobile-and-server': {
64
- label: 'Mobile + server only (no admin UI, no site)',
65
- includes: ['server', 'mobile', 'misc', 'scripts', 'doc'],
66
- wantsMobile: true,
67
- wantsServer: true,
68
- wantsAdminUi: false,
69
- wantsSite: false,
70
- },
71
- 'just-mobile': {
72
- label: 'Just mobile (assumes external API)',
73
- includes: ['mobile', 'scripts', 'doc'],
74
- wantsMobile: true,
75
- wantsServer: false,
76
- wantsAdminUi: false,
77
- wantsSite: false,
78
- },
79
- };
80
-
81
- const ROOT_FILES_BY_PRESET = {
82
- everything: ['CLAUDE.md', 'AGENTS.md', 'README.md',
83
- 'architecture.md', 'code.md',
84
- 'install.sh', 'seed.sh', 'dev.sh'],
85
- 'web-only': ['CLAUDE.md', 'AGENTS.md', 'README.md',
86
- 'architecture.md', 'code.md',
87
- 'install.sh', 'seed.sh', 'dev.sh'],
88
- 'mobile-and-site': ['CLAUDE.md', 'AGENTS.md', 'README.md',
89
- 'code.md', 'install.sh', 'dev.sh'],
90
- 'mobile-and-server': ['CLAUDE.md', 'AGENTS.md', 'README.md',
91
- 'architecture.md', 'code.md',
92
- 'install.sh', 'seed.sh', 'dev.sh'],
93
- 'just-mobile': ['CLAUDE.md', 'README.md', 'code.md',
94
- 'install.sh', 'dev.sh'],
95
- };
34
+ // A project is any combination of four components picked one-by-one
35
+ // at create time (see template-variables.mjs for the component
36
+ // template-paths mapping shared with `umpordez update`). The CLI
37
+ // copies the whole template then scrubs what wasn't picked — keeps
38
+ // the template structure unconditional and the create-time logic
39
+ // dead simple.
96
40
 
97
41
  function slugify(text) {
98
42
  return text
@@ -113,23 +57,135 @@ function toReverseDomain(domain) {
113
57
  return parts.reverse().join('.');
114
58
  }
115
59
 
60
+ // Ask for each component one-by-one instead of a fixed preset list.
61
+ // The questions adapt to earlier answers:
62
+ // - the server API pitch changes when a mobile app was picked (a
63
+ // mobile-only backend for auth / IAP validation / file serving
64
+ // is a first-class use case, not "everything minus stuff")
65
+ // - the admin webapp is only offered when there's an API for it
66
+ // - the landing-page flavor is only asked when it's genuinely
67
+ // ambiguous (site + mobile + admin webapp)
68
+ async function promptComponents() {
69
+ const { mobile } = await inquirer.prompt([{
70
+ type: 'confirm',
71
+ name: 'mobile',
72
+ message: 'Mobile app? (Expo + React Native + offline SQLite '
73
+ + '+ IAP)',
74
+ default: true,
75
+ }]);
76
+
77
+ const { server } = await inquirer.prompt([{
78
+ type: 'confirm',
79
+ name: 'server',
80
+ message: mobile
81
+ ? 'Server API? (Express + PostgreSQL — powers auth, '
82
+ + 'IAP validation, file serving and backups for the '
83
+ + 'app)'
84
+ : 'Server API? (Express + PostgreSQL, multi-tenant auth)',
85
+ default: true,
86
+ }]);
87
+
88
+ let adminUi = false;
89
+ if (server) {
90
+ ({ adminUi } = await inquirer.prompt([{
91
+ type: 'confirm',
92
+ name: 'adminUi',
93
+ message: 'Admin webapp? (React SPA + shadcn/ui, '
94
+ + 'role-based)',
95
+ default: true,
96
+ }]));
97
+ } else {
98
+ console.log(dim(
99
+ ' (admin webapp skipped — it needs the server API)'
100
+ ));
101
+ }
102
+
103
+ const { site } = await inquirer.prompt([{
104
+ type: 'confirm',
105
+ name: 'site',
106
+ message: 'Public site? (EJS + i18n + SEO landing page)',
107
+ default: true,
108
+ }]);
109
+
110
+ const components = { server, adminUi, site, mobile };
111
+
112
+ if (!server && !adminUi && !site && !mobile) {
113
+ console.log(red(' Pick at least one component.'));
114
+ console.log('');
115
+ return promptComponents();
116
+ }
117
+
118
+ // Landing flavor. Only ambiguous when the site coexists with
119
+ // both an app to advertise and an admin webapp to sign up into.
120
+ let appLanding = false;
121
+ if (site && mobile && !adminUi) {
122
+ appLanding = true;
123
+ console.log(dim(
124
+ ' (landing page: app-store flavor — download CTAs, '
125
+ + 'no signup to point at)'
126
+ ));
127
+ } else if (site && mobile && adminUi) {
128
+ const { landing } = await inquirer.prompt([{
129
+ type: 'list',
130
+ name: 'landing',
131
+ message: 'Landing page flavor:',
132
+ choices: [
133
+ { name: 'SaaS (CTAs → admin signup)', value: 'saas' },
134
+ { name: 'App store (download buttons + pricing)',
135
+ value: 'app' },
136
+ ],
137
+ default: 'saas',
138
+ }]);
139
+ appLanding = landing === 'app';
140
+ }
141
+
142
+ // Sign-in providers for the mobile app. Email+password always
143
+ // ships; Apple and Google are opt-out. Disabling Apple also
144
+ // strips the entitlement + config plugin from app.json so local
145
+ // iOS builds don't require the capability on the provisioning
146
+ // profile.
147
+ let authApple = true;
148
+ let authGoogle = true;
149
+ let iap = true;
150
+ if (mobile) {
151
+ ({ authApple } = await inquirer.prompt([{
152
+ type: 'confirm',
153
+ name: 'authApple',
154
+ message: 'Mobile sign-in with Apple? (email+password '
155
+ + 'always included)',
156
+ default: true,
157
+ }]));
158
+ ({ authGoogle } = await inquirer.prompt([{
159
+ type: 'confirm',
160
+ name: 'authGoogle',
161
+ message: 'Mobile sign-in with Google? (server-side '
162
+ + 'OAuth flow)',
163
+ default: true,
164
+ }]));
165
+ // Disabling IAP strips the expo-iap dependency, its config
166
+ // plugin and the Android BILLING permission — PremiumGate
167
+ // then renders everything unlocked (a fully free app).
168
+ ({ iap } = await inquirer.prompt([{
169
+ type: 'confirm',
170
+ name: 'iap',
171
+ message: 'In-app purchases? (premium subscriptions via '
172
+ + 'expo-iap + server receipt validation)',
173
+ default: true,
174
+ }]));
175
+ }
176
+
177
+ return { components, appLanding, authApple, authGoogle, iap };
178
+ }
179
+
116
180
  async function promptProjectDetails() {
117
181
  console.log(green.bold(' Project Setup'));
118
182
  console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
119
183
  console.log('');
120
184
 
121
- const { preset } = await inquirer.prompt([{
122
- type: 'list',
123
- name: 'preset',
124
- message: 'What do you want to generate?',
125
- choices: Object.entries(PRESETS).map(([value, def]) => ({
126
- name: def.label,
127
- value,
128
- })),
129
- default: 'everything',
130
- }]);
131
-
132
- const presetDef = PRESETS[preset];
185
+ const {
186
+ components, appLanding, authApple, authGoogle, iap
187
+ } = await promptComponents();
188
+ const preset = presetNameForComponents(components);
133
189
 
134
190
  const { name } = await inquirer.prompt([{
135
191
  type: 'input',
@@ -165,7 +221,7 @@ async function promptProjectDetails() {
165
221
  },
166
222
  ];
167
223
 
168
- const serverQuestions = !presetDef.wantsServer ? [] : [
224
+ const serverQuestions = !components.server ? [] : [
169
225
  {
170
226
  type: 'input',
171
227
  name: 'dbName',
@@ -204,7 +260,7 @@ async function promptProjectDetails() {
204
260
  },
205
261
  ];
206
262
 
207
- const webUiQuestions = !presetDef.wantsAdminUi ? [] : [
263
+ const webUiQuestions = !components.adminUi ? [] : [
208
264
  {
209
265
  type: 'input',
210
266
  name: 'adminUiPort',
@@ -214,7 +270,7 @@ async function promptProjectDetails() {
214
270
  },
215
271
  ];
216
272
 
217
- const siteQuestions = !presetDef.wantsSite ? [] : [
273
+ const siteQuestions = !components.site ? [] : [
218
274
  {
219
275
  type: 'input',
220
276
  name: 'sitePort',
@@ -254,7 +310,7 @@ async function promptProjectDetails() {
254
310
  },
255
311
  ];
256
312
 
257
- const mobileQuestions = !presetDef.wantsMobile ? [] : [
313
+ const mobileQuestions = !components.mobile ? [] : [
258
314
  {
259
315
  type: 'input',
260
316
  name: 'iosBundleId',
@@ -306,7 +362,11 @@ async function promptProjectDetails() {
306
362
 
307
363
  return {
308
364
  preset,
309
- presetDef,
365
+ components,
366
+ appLanding,
367
+ authApple,
368
+ authGoogle,
369
+ iap,
310
370
  name: name.trim(),
311
371
  // Defaults for the parts the user didn't see — they're still
312
372
  // referenced by replacements (some unconditionally), so we fill
@@ -334,54 +394,70 @@ function validatePort(v) {
334
394
  : 'Must be a valid port number';
335
395
  }
336
396
 
397
+ function componentsLabel(c) {
398
+ const parts = [];
399
+ if (c.server) parts.push('server API');
400
+ if (c.adminUi) parts.push('admin webapp');
401
+ if (c.site) parts.push('site');
402
+ if (c.mobile) parts.push('mobile');
403
+ return parts.join(' + ');
404
+ }
405
+
337
406
  function showSummary(details) {
338
- const { presetDef } = details;
407
+ const c = details.components;
339
408
  console.log('');
340
409
  console.log(green.bold(' Project Summary'));
341
410
  console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
342
411
  console.log('');
343
- console.log(` ${chalk.bold('Preset:')} ${cyan(presetDef.label)}`);
412
+ console.log(` ${chalk.bold('Components:')} ${cyan(componentsLabel(c))}`);
344
413
  console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
345
414
  console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
346
415
  console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
347
416
  console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
348
- if (presetDef.wantsServer) {
417
+ if (c.server) {
349
418
  console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
350
419
  console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
351
420
  }
352
421
  console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
353
422
  const ports = [];
354
- if (presetDef.wantsServer) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
355
- if (presetDef.wantsAdminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
356
- if (presetDef.wantsSite) ports.push(`Site ${cyan(details.sitePort)}`);
423
+ if (c.server) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
424
+ if (c.adminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
425
+ if (c.site) ports.push(`Site ${cyan(details.sitePort)}`);
357
426
  if (ports.length) {
358
427
  console.log(` ${chalk.bold('Ports:')} ${ports.join(' | ')}`);
359
428
  }
360
429
  console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('██')} ${details.primaryColor}`);
361
430
  console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
362
- if (presetDef.wantsSite) {
431
+ if (c.site) {
363
432
  console.log(` ${chalk.bold('Landing:')} ${cyan(
364
- presetDef.appLanding ? 'app store (download)' : 'SaaS (signup)'
433
+ details.appLanding ? 'app store (download)' : 'SaaS (signup)'
365
434
  )}`);
366
435
  }
367
- if (presetDef.wantsMobile) {
436
+ if (c.mobile) {
368
437
  console.log(` ${chalk.bold('iOS:')} ${details.iosBundleId}`);
369
438
  console.log(` ${chalk.bold('Android:')} ${details.androidPackage}`);
370
439
  console.log(` ${chalk.bold('Scheme:')} ${details.mobileScheme}://`);
371
440
  console.log(` ${chalk.bold('Splash:')} ${details.splashStyle === 'animated'
372
441
  ? 'animated (glow + logo)'
373
442
  : 'instant show-up'}`);
443
+ const providers = ['email+password'];
444
+ if (details.authApple) providers.push('Apple');
445
+ if (details.authGoogle) providers.push('Google');
446
+ console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
447
+ console.log(` ${chalk.bold('IAP:')} ${details.iap
448
+ ? 'premium subscriptions (expo-iap)'
449
+ : 'none (all features unlocked)'}`);
374
450
  }
375
451
  console.log('');
376
452
  console.log(dim(' Generates:'));
377
- if (presetDef.wantsServer) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
378
- if (presetDef.wantsAdminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
379
- if (presetDef.wantsSite) console.log(dim(` ${green('>')} ${presetDef.appLanding
453
+ if (c.server) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
454
+ if (c.adminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
455
+ if (c.site) console.log(dim(` ${green('>')} ${details.appLanding
380
456
  ? 'App landing page (store buttons + pricing)'
381
457
  : 'Public site (EJS + Tailwind)'}`));
382
- if (presetDef.wantsMobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
383
- if (presetDef.wantsMobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
384
- if (presetDef.wantsServer) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
458
+ if (c.mobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
459
+ if (c.mobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
460
+ if (c.server) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
385
461
  console.log('');
386
462
  }
387
463
 
@@ -428,12 +504,12 @@ const ALWAYS_KEEP = new Set(['.claude']);
428
504
  // renamed back to `.gitignore` during the file-rename pass below.
429
505
  const ALWAYS_KEEP_FILES = new Set(['gitignore', '.editorconfig']);
430
506
 
431
- export async function copyTemplate(outputDir, preset) {
432
- const presetDef = PRESETS[preset];
507
+ export async function copyTemplate(outputDir, components, appLanding) {
508
+ const includes = includesForComponents(components);
433
509
  await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
434
510
 
435
511
  // Top-level dirs to keep
436
- const keep = new Set([...presetDef.includes, ...ALWAYS_KEEP]);
512
+ const keep = new Set([...includes, ...ALWAYS_KEEP]);
437
513
  const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
438
514
 
439
515
  for (const entry of entries) {
@@ -442,7 +518,7 @@ export async function copyTemplate(outputDir, preset) {
442
518
  }
443
519
  // Special-case ui/: keep only the requested children
444
520
  if (entry.name === 'ui') {
445
- const wantedChildren = presetDef.includes
521
+ const wantedChildren = includes
446
522
  .filter(p => p.startsWith('ui/'))
447
523
  .map(p => p.slice('ui/'.length));
448
524
  if (wantedChildren.length === 0) {
@@ -475,7 +551,7 @@ export async function copyTemplate(outputDir, preset) {
475
551
  }
476
552
 
477
553
  // Top-level files: drop dev.sh / seed.sh etc. when not relevant
478
- const allowedFiles = new Set(ROOT_FILES_BY_PRESET[preset] ?? []);
554
+ const allowedFiles = new Set(rootFilesForComponents(components));
479
555
  const rootEntries = await fs.promises.readdir(outputDir, {
480
556
  withFileTypes: true
481
557
  });
@@ -489,11 +565,11 @@ export async function copyTemplate(outputDir, preset) {
489
565
  }
490
566
  }
491
567
 
492
- // Landing-page variant swap. App-focused presets replace the SaaS
568
+ // Landing-page variant swap. App-focused projects replace the SaaS
493
569
  // landing with the app-marketing one before placeholder replacement
494
570
  // runs (so the swapped files get their placeholders filled too). The
495
571
  // _variants staging dir is always removed — it never ships.
496
- if (presetDef.appLanding) {
572
+ if (appLanding) {
497
573
  for (const [variant, canonical] of APP_LANDING_SWAPS) {
498
574
  const from = path.join(outputDir, variant);
499
575
  const to = path.join(outputDir, canonical);
@@ -551,9 +627,11 @@ export async function createProject() {
551
627
 
552
628
  console.log('');
553
629
 
554
- // [1/5] Copy template (preset-aware pruning)
630
+ // [1/5] Copy template (component-aware pruning)
555
631
  console.log(green('[1/5]') + ' Copying template files...');
556
- await copyTemplate(outputDir, details.preset);
632
+ await copyTemplate(
633
+ outputDir, details.components, details.appLanding
634
+ );
557
635
 
558
636
  // [2/5] Replace placeholders
559
637
  const files = await getAllFiles(outputDir);
@@ -604,7 +682,7 @@ export async function createProject() {
604
682
  // primary color + first letter of the project name. A failure
605
683
  // here is fatal (without these files, expo prebuild + the app
606
684
  // splash both break), so we surface the error instead of swallowing.
607
- if (details.presetDef.wantsMobile) {
685
+ if (details.components.mobile) {
608
686
  const mobileAssets = path.join(
609
687
  outputDir, 'mobile', details.slug, 'assets'
610
688
  );
@@ -629,7 +707,7 @@ export async function createProject() {
629
707
  'seed.sh',
630
708
  'server/knex.sh',
631
709
  ];
632
- if (details.presetDef.wantsMobile) {
710
+ if (details.components.mobile) {
633
711
  scripts.push(
634
712
  'scripts/bump-version.sh',
635
713
  'scripts/prebuild.sh',
@@ -641,7 +719,7 @@ export async function createProject() {
641
719
  'scripts/screenshots-capture.sh'
642
720
  );
643
721
  }
644
- if (details.presetDef.wantsServer) {
722
+ if (details.components.server) {
645
723
  scripts.push('misc/build-local.sh');
646
724
  }
647
725
 
@@ -706,10 +784,10 @@ async function renameDirs(root, slug) {
706
784
  }
707
785
 
708
786
  function showNextSteps(details) {
709
- const { presetDef } = details;
787
+ const c = details.components;
710
788
  console.log(chalk.bold(' Next steps:'));
711
789
  console.log(` ${green('1.')} cd ${details.outputDir}`);
712
- if (presetDef.wantsServer) {
790
+ if (c.server) {
713
791
  console.log(` ${green('2.')} ./install.sh`);
714
792
  console.log(` ${green('3.')} ./seed.sh`);
715
793
  console.log(` ${green('4.')} ./dev.sh`);
@@ -720,16 +798,16 @@ function showNextSteps(details) {
720
798
  console.log(` ${green('3.')} ./dev.sh`);
721
799
  }
722
800
  console.log('');
723
- if (presetDef.wantsAdminUi) {
801
+ if (c.adminUi) {
724
802
  console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
725
803
  }
726
- if (presetDef.wantsServer) {
804
+ if (c.server) {
727
805
  console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
728
806
  }
729
- if (presetDef.wantsSite) {
807
+ if (c.site) {
730
808
  console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
731
809
  }
732
- if (presetDef.wantsMobile) {
810
+ if (c.mobile) {
733
811
  console.log(dim(` Mobile: cd mobile/${details.slug} && npm run ios | android | web`));
734
812
  console.log(dim(` Store kit: mobile/${details.slug}/docs/ (listing, copy, screenshots) + CHANGELOG.md`));
735
813
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "umpordez",
3
3
  "type": "module",
4
- "version": "1.3.2",
4
+ "version": "1.5.0",
5
5
  "description": "SaaS starter kit generator — server, admin SPA, public site (i18n), mobile app (offline-first, IAP, native push)",
6
6
  "main": "cli.mjs",
7
7
  "scripts": {
@@ -312,7 +312,7 @@ if (!ok) return;
312
312
  - **Services own data access** — pages never call `getDb()` directly
313
313
  - **User-scoped writes check `demandSigned()`** — reads always work, writes no-op when gate closed
314
314
  - Every string through `t()` — no bare text in JSX
315
- - NitroModules crash in Expo Go — lazy-load with `try/require`
315
+ - Native-module crash in Expo Go (e.g. `expo-iap`) — lazy-load with `try/require`
316
316
  - Never `expo prebuild --clean` (wipes native code; use `scripts/prebuild.sh`)
317
317
 
318
318
  ### i18n Coverage
@@ -55,10 +55,11 @@ URLs:
55
55
  └── .claude/settings.json Permission allowlist for routine commands
56
56
  ```
57
57
 
58
- Folders that the preset didn't ship are simply absent. `dev.sh`
59
- and `install.sh` guard on existence per directory, so the same
60
- script works for every preset (everything / web-only /
61
- mobile-and-site / mobile-and-server / just-mobile).
58
+ Folders the scaffold didn't ship are simply absent. Components
59
+ (server API / admin webapp / public site / mobile app) are picked
60
+ one-by-one at create time; `dev.sh` and `install.sh` guard on
61
+ existence per directory, so the same script works for every
62
+ combination.
62
63
 
63
64
  ## Multi-tenancy model
64
65
 
@@ -96,7 +97,7 @@ HTTP request builds a fresh `Context` with every model instantiated
96
97
  | 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
97
98
  | 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore |
98
99
  | 3 | server-driven content sync + HMAC-signed presigned asset URLs |
99
- | 4 | IAP via `react-native-iap`, premium gate, foreground re-check |
100
+ | 4 | IAP via `expo-iap` (optional at scaffold), premium gate, foreground re-check |
100
101
  | 5 | custom-native local notifications with cold-start tap dispatch |
101
102
  | 6 | Apple Health + Android Health Connect unified surface |
102
103
  | 7 | native cookbook (Hello iOS/Android + Live Activity scaffold) |
@@ -16,7 +16,7 @@ integrations.
16
16
  ```bash
17
17
  ./install.sh # install all node deps + scaffold .env files
18
18
  ./seed.sh # create db + run migrations + seed admin user
19
- ./dev.sh # start every web service this preset shipped
19
+ ./dev.sh # start every service this project shipped
20
20
  ./dev.sh --mobile # also start Expo Metro
21
21
  ```
22
22
 
@@ -60,9 +60,9 @@ URLs (web):
60
60
  └── misc/ systemd units, nginx samples
61
61
  ```
62
62
 
63
- Folders the preset didn't ship are simply absent — `dev.sh` /
63
+ Folders the scaffold didn't ship are simply absent — `dev.sh` /
64
64
  `install.sh` guard on directory existence so the scripts work for
65
- every preset.
65
+ every component combination.
66
66
 
67
67
  ## Multi-tenancy model
68
68
 
@@ -112,7 +112,7 @@ HTTP request builds a fresh `Context` with every model instantiated
112
112
  | 1 | auth flows, theme, navigation, splash, toasts, typed fetch |
113
113
  | 2 | offline SQLite (USER vs SHARED tables), cloud backup/restore via S3 |
114
114
  | 3 | server-driven content sync + HMAC-signed presigned asset URLs |
115
- | 4 | IAP (`react-native-iap`), receipt validation, premium gate, foreground re-check |
115
+ | 4 | IAP (`expo-iap`, optional at scaffold), receipt validation, premium gate, foreground re-check |
116
116
  | 5 | custom-native local notifications with cold-start tap dispatch |
117
117
  | 6 | Apple Health + Android Health Connect unified surface |
118
118
  | 7 | native modules cookbook (Hello iOS/Android + Live Activity scaffold + gotchas) |
@@ -514,8 +514,11 @@ POST /api/logout</code></pre>
514
514
  <section id="iap">
515
515
  <h2>IAP &amp; premium <span class="badge">Phase 4</span></h2>
516
516
  <p>
517
- <code>react-native-iap</code> v15 lazy-loaded with
518
- try/require so Expo Go gets graceful no-ops.
517
+ <code>expo-iap</code> lazy-loaded with try/require so
518
+ Expo Go gets graceful no-ops. Optional at scaffold
519
+ time &mdash; when disabled, the dependency ships
520
+ stripped and PremiumGate renders everything
521
+ unlocked.
519
522
  </p>
520
523
  <pre><code>const { activatePremium } = useAuth();
521
524
  const purchase = await purchaseSubscription(PRODUCT_IDS.annual);
@@ -25,9 +25,8 @@ src/
25
25
  │ ├── db/ SQLite (USER_TABLES vs SHARED_TABLES)
26
26
  │ ├── backup/ Two-phase S3 backup (premium)
27
27
  │ ├── content/ Versioned catalog + HMAC-signed URLs
28
- │ ├── iap/ react-native-iap wrapped lazily
29
- ├── notifications/ Custom-native, cold-start tap dispatch
30
- │ └── health/ Apple Health + Health Connect unified
28
+ │ ├── iap/ expo-iap wrapped lazily (optional)
29
+ └── notifications/ Custom-native, cold-start tap dispatch
31
30
  └── components/
32
31
  ├── AnimatedSplash.tsx
33
32
  ├── ToastConfig.tsx
@@ -87,14 +86,27 @@ hasCompletedOnboarding
87
86
  - **Every user-facing string goes through `t()`.** Even pt-BR
88
87
  default strings. No bare strings in JSX.
89
88
 
90
- - **NitroModules + Expo Go = crash on import.** Anything using
91
- nitro (e.g. `react-native-iap` v15) must be lazy-loaded with
92
- `try/require`. See `services/iap/index.ts`.
89
+ - **Native modules + Expo Go = crash on import.** Anything whose
90
+ native side isn't bundled in Expo Go (e.g. `expo-iap`) must be
91
+ lazy-loaded with `try/require`. See `services/iap/index.ts`.
92
+ IAP itself is optional at scaffold time: `config.iap === false`
93
+ means the expo-iap dependency was never installed and PremiumGate
94
+ renders everything unlocked.
93
95
 
94
96
  - **`expo prebuild --clean` will silently delete native code.**
95
97
  Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
96
98
  reset, move customizations into `plugins/` first.
97
99
 
100
+ - **Dependency versions follow the Expo SDK.** Never bump
101
+ `react`, `react-native`, or any `expo-*` / `react-native-*`
102
+ native package by hand — run `npx expo install --check` (or
103
+ `--fix`) so everything stays on the SDK's bundled versions.
104
+ `typescript` is intentionally in `expo.install.exclude`
105
+ (package.json): the SDK wants TS 6 but `react-i18next@15`
106
+ peer-requires TS 5 — keep `~5.9.x` until react-i18next is
107
+ bumped to v17+ (which drags i18next to v26 and drops the
108
+ `compatibilityJSON: 'v3'` Hermes escape hatch in `src/i18n/`).
109
+
98
110
  ## Splash
99
111
 
100
112
  `config.ts` exports `SPLASH_ANIMATED` (set at scaffold time). When