umpordez 1.3.1 → 1.4.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.
- package/.claude/settings.local.json +7 -1
- package/cli.mjs +1 -1
- package/create.mjs +193 -117
- package/package.json +1 -1
- package/template/CLAUDE.md +5 -4
- package/template/README.md +3 -3
- package/template/gitignore +13 -0
- package/template/mobile/{{PROJECT_SLUG}}/CLAUDE.md +11 -2
- package/template/mobile/{{PROJECT_SLUG}}/app.json +13 -31
- package/template/mobile/{{PROJECT_SLUG}}/gitignore +29 -0
- package/template/mobile/{{PROJECT_SLUG}}/package.json +22 -20
- package/template/mobile/{{PROJECT_SLUG}}/src/components/AnimatedSplash.tsx +5 -1
- package/template/mobile/{{PROJECT_SLUG}}/src/config.ts +11 -0
- package/template/mobile/{{PROJECT_SLUG}}/src/pages/auth/AuthLanding.tsx +55 -50
- package/template-variables.mjs +127 -0
- package/update.mjs +59 -49
- package/template/mobile/{{PROJECT_SLUG}}/src/services/health/index.ts +0 -238
|
@@ -48,7 +48,13 @@
|
|
|
48
48
|
"Bash(git tag *)",
|
|
49
49
|
"Bash(npm pack *)",
|
|
50
50
|
"Read(//Users/ligeiro/dev/dropa/**)",
|
|
51
|
-
"Read(//Users/ligeiro/dev/growerheleper-v2/app/**)"
|
|
51
|
+
"Read(//Users/ligeiro/dev/growerheleper-v2/app/**)",
|
|
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 *)"
|
|
52
58
|
]
|
|
53
59
|
}
|
|
54
60
|
}
|
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('>')}
|
|
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
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,124 @@ 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
|
+
if (mobile) {
|
|
150
|
+
({ authApple } = await inquirer.prompt([{
|
|
151
|
+
type: 'confirm',
|
|
152
|
+
name: 'authApple',
|
|
153
|
+
message: 'Mobile sign-in with Apple? (email+password '
|
|
154
|
+
+ 'always included)',
|
|
155
|
+
default: true,
|
|
156
|
+
}]));
|
|
157
|
+
({ authGoogle } = await inquirer.prompt([{
|
|
158
|
+
type: 'confirm',
|
|
159
|
+
name: 'authGoogle',
|
|
160
|
+
message: 'Mobile sign-in with Google? (server-side '
|
|
161
|
+
+ 'OAuth flow)',
|
|
162
|
+
default: true,
|
|
163
|
+
}]));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return { components, appLanding, authApple, authGoogle };
|
|
167
|
+
}
|
|
168
|
+
|
|
116
169
|
async function promptProjectDetails() {
|
|
117
170
|
console.log(green.bold(' Project Setup'));
|
|
118
171
|
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
119
172
|
console.log('');
|
|
120
173
|
|
|
121
|
-
const {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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];
|
|
174
|
+
const {
|
|
175
|
+
components, appLanding, authApple, authGoogle
|
|
176
|
+
} = await promptComponents();
|
|
177
|
+
const preset = presetNameForComponents(components);
|
|
133
178
|
|
|
134
179
|
const { name } = await inquirer.prompt([{
|
|
135
180
|
type: 'input',
|
|
@@ -165,7 +210,7 @@ async function promptProjectDetails() {
|
|
|
165
210
|
},
|
|
166
211
|
];
|
|
167
212
|
|
|
168
|
-
const serverQuestions = !
|
|
213
|
+
const serverQuestions = !components.server ? [] : [
|
|
169
214
|
{
|
|
170
215
|
type: 'input',
|
|
171
216
|
name: 'dbName',
|
|
@@ -204,7 +249,7 @@ async function promptProjectDetails() {
|
|
|
204
249
|
},
|
|
205
250
|
];
|
|
206
251
|
|
|
207
|
-
const webUiQuestions = !
|
|
252
|
+
const webUiQuestions = !components.adminUi ? [] : [
|
|
208
253
|
{
|
|
209
254
|
type: 'input',
|
|
210
255
|
name: 'adminUiPort',
|
|
@@ -214,7 +259,7 @@ async function promptProjectDetails() {
|
|
|
214
259
|
},
|
|
215
260
|
];
|
|
216
261
|
|
|
217
|
-
const siteQuestions = !
|
|
262
|
+
const siteQuestions = !components.site ? [] : [
|
|
218
263
|
{
|
|
219
264
|
type: 'input',
|
|
220
265
|
name: 'sitePort',
|
|
@@ -254,7 +299,7 @@ async function promptProjectDetails() {
|
|
|
254
299
|
},
|
|
255
300
|
];
|
|
256
301
|
|
|
257
|
-
const mobileQuestions = !
|
|
302
|
+
const mobileQuestions = !components.mobile ? [] : [
|
|
258
303
|
{
|
|
259
304
|
type: 'input',
|
|
260
305
|
name: 'iosBundleId',
|
|
@@ -306,7 +351,10 @@ async function promptProjectDetails() {
|
|
|
306
351
|
|
|
307
352
|
return {
|
|
308
353
|
preset,
|
|
309
|
-
|
|
354
|
+
components,
|
|
355
|
+
appLanding,
|
|
356
|
+
authApple,
|
|
357
|
+
authGoogle,
|
|
310
358
|
name: name.trim(),
|
|
311
359
|
// Defaults for the parts the user didn't see — they're still
|
|
312
360
|
// referenced by replacements (some unconditionally), so we fill
|
|
@@ -334,54 +382,67 @@ function validatePort(v) {
|
|
|
334
382
|
: 'Must be a valid port number';
|
|
335
383
|
}
|
|
336
384
|
|
|
385
|
+
function componentsLabel(c) {
|
|
386
|
+
const parts = [];
|
|
387
|
+
if (c.server) parts.push('server API');
|
|
388
|
+
if (c.adminUi) parts.push('admin webapp');
|
|
389
|
+
if (c.site) parts.push('site');
|
|
390
|
+
if (c.mobile) parts.push('mobile');
|
|
391
|
+
return parts.join(' + ');
|
|
392
|
+
}
|
|
393
|
+
|
|
337
394
|
function showSummary(details) {
|
|
338
|
-
const
|
|
395
|
+
const c = details.components;
|
|
339
396
|
console.log('');
|
|
340
397
|
console.log(green.bold(' Project Summary'));
|
|
341
398
|
console.log(chalk.hex('#2c2c2c')(' ' + '─'.repeat(40)));
|
|
342
399
|
console.log('');
|
|
343
|
-
console.log(` ${chalk.bold('
|
|
400
|
+
console.log(` ${chalk.bold('Components:')} ${cyan(componentsLabel(c))}`);
|
|
344
401
|
console.log(` ${chalk.bold('Name:')} ${cyan(details.name)}`);
|
|
345
402
|
console.log(` ${chalk.bold('Slug:')} ${details.slug}`);
|
|
346
403
|
console.log(` ${chalk.bold('Description:')} ${details.description || dim('(none)')}`);
|
|
347
404
|
console.log(` ${chalk.bold('Domain:')} ${cyan(details.domain)}`);
|
|
348
|
-
if (
|
|
405
|
+
if (c.server) {
|
|
349
406
|
console.log(` ${chalk.bold('Database:')} ${details.dbName}`);
|
|
350
407
|
console.log(` ${chalk.bold('Admin:')} ${details.adminEmail}`);
|
|
351
408
|
}
|
|
352
409
|
console.log(` ${chalk.bold('Output:')} ${details.outputDir}`);
|
|
353
410
|
const ports = [];
|
|
354
|
-
if (
|
|
355
|
-
if (
|
|
356
|
-
if (
|
|
411
|
+
if (c.server) ports.push(`API ${cyan(details.apiPort)} | Site API ${cyan(details.siteApiPort)}`);
|
|
412
|
+
if (c.adminUi) ports.push(`Admin ${cyan(details.adminUiPort)}`);
|
|
413
|
+
if (c.site) ports.push(`Site ${cyan(details.sitePort)}`);
|
|
357
414
|
if (ports.length) {
|
|
358
415
|
console.log(` ${chalk.bold('Ports:')} ${ports.join(' | ')}`);
|
|
359
416
|
}
|
|
360
417
|
console.log(` ${chalk.bold('Color:')} ${chalk.hex(details.primaryColor)('██')} ${details.primaryColor}`);
|
|
361
418
|
console.log(` ${chalk.bold('Theme:')} ${details.defaultTheme}`);
|
|
362
|
-
if (
|
|
419
|
+
if (c.site) {
|
|
363
420
|
console.log(` ${chalk.bold('Landing:')} ${cyan(
|
|
364
|
-
|
|
421
|
+
details.appLanding ? 'app store (download)' : 'SaaS (signup)'
|
|
365
422
|
)}`);
|
|
366
423
|
}
|
|
367
|
-
if (
|
|
424
|
+
if (c.mobile) {
|
|
368
425
|
console.log(` ${chalk.bold('iOS:')} ${details.iosBundleId}`);
|
|
369
426
|
console.log(` ${chalk.bold('Android:')} ${details.androidPackage}`);
|
|
370
427
|
console.log(` ${chalk.bold('Scheme:')} ${details.mobileScheme}://`);
|
|
371
428
|
console.log(` ${chalk.bold('Splash:')} ${details.splashStyle === 'animated'
|
|
372
429
|
? 'animated (glow + logo)'
|
|
373
430
|
: 'instant show-up'}`);
|
|
431
|
+
const providers = ['email+password'];
|
|
432
|
+
if (details.authApple) providers.push('Apple');
|
|
433
|
+
if (details.authGoogle) providers.push('Google');
|
|
434
|
+
console.log(` ${chalk.bold('Sign-in:')} ${providers.join(' | ')}`);
|
|
374
435
|
}
|
|
375
436
|
console.log('');
|
|
376
437
|
console.log(dim(' Generates:'));
|
|
377
|
-
if (
|
|
378
|
-
if (
|
|
379
|
-
if (
|
|
438
|
+
if (c.server) console.log(dim(` ${green('>')} Admin API + Site API (Express + TS)`));
|
|
439
|
+
if (c.adminUi) console.log(dim(` ${green('>')} React admin panel (Vite + shadcn/ui)`));
|
|
440
|
+
if (c.site) console.log(dim(` ${green('>')} ${details.appLanding
|
|
380
441
|
? 'App landing page (store buttons + pricing)'
|
|
381
442
|
: 'Public site (EJS + Tailwind)'}`));
|
|
382
|
-
if (
|
|
383
|
-
if (
|
|
384
|
-
if (
|
|
443
|
+
if (c.mobile) console.log(dim(` ${green('>')} Mobile app (Expo + TS + auth + theme)`));
|
|
444
|
+
if (c.mobile) console.log(dim(` ${green('>')} Store launch kit (listing, copy, changelog, screenshots)`));
|
|
445
|
+
if (c.server) console.log(dim(` ${green('>')} PostgreSQL migrations + seed`));
|
|
385
446
|
console.log('');
|
|
386
447
|
}
|
|
387
448
|
|
|
@@ -422,12 +483,18 @@ function replacePlaceholders(content, replacements) {
|
|
|
422
483
|
// config, git ignore) doesn't belong to any single sub-app.
|
|
423
484
|
const ALWAYS_KEEP = new Set(['.claude']);
|
|
424
485
|
|
|
425
|
-
|
|
426
|
-
|
|
486
|
+
// Top-level files kept for every preset (not tied to any sub-app).
|
|
487
|
+
// The gitignore ships under a dot-less name because npm strips any
|
|
488
|
+
// file literally named `.gitignore` from published tarballs — it is
|
|
489
|
+
// renamed back to `.gitignore` during the file-rename pass below.
|
|
490
|
+
const ALWAYS_KEEP_FILES = new Set(['gitignore', '.editorconfig']);
|
|
491
|
+
|
|
492
|
+
export async function copyTemplate(outputDir, components, appLanding) {
|
|
493
|
+
const includes = includesForComponents(components);
|
|
427
494
|
await fs.promises.cp(TEMPLATE_DIR, outputDir, { recursive: true });
|
|
428
495
|
|
|
429
496
|
// Top-level dirs to keep
|
|
430
|
-
const keep = new Set([...
|
|
497
|
+
const keep = new Set([...includes, ...ALWAYS_KEEP]);
|
|
431
498
|
const entries = await fs.promises.readdir(outputDir, { withFileTypes: true });
|
|
432
499
|
|
|
433
500
|
for (const entry of entries) {
|
|
@@ -436,7 +503,7 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
436
503
|
}
|
|
437
504
|
// Special-case ui/: keep only the requested children
|
|
438
505
|
if (entry.name === 'ui') {
|
|
439
|
-
const wantedChildren =
|
|
506
|
+
const wantedChildren = includes
|
|
440
507
|
.filter(p => p.startsWith('ui/'))
|
|
441
508
|
.map(p => p.slice('ui/'.length));
|
|
442
509
|
if (wantedChildren.length === 0) {
|
|
@@ -469,7 +536,7 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
469
536
|
}
|
|
470
537
|
|
|
471
538
|
// Top-level files: drop dev.sh / seed.sh etc. when not relevant
|
|
472
|
-
const allowedFiles = new Set(
|
|
539
|
+
const allowedFiles = new Set(rootFilesForComponents(components));
|
|
473
540
|
const rootEntries = await fs.promises.readdir(outputDir, {
|
|
474
541
|
withFileTypes: true
|
|
475
542
|
});
|
|
@@ -477,16 +544,17 @@ export async function copyTemplate(outputDir, preset) {
|
|
|
477
544
|
if (entry.isDirectory()) {
|
|
478
545
|
continue;
|
|
479
546
|
}
|
|
480
|
-
if (!allowedFiles.has(entry.name)
|
|
547
|
+
if (!allowedFiles.has(entry.name)
|
|
548
|
+
&& !ALWAYS_KEEP_FILES.has(entry.name)) {
|
|
481
549
|
await fs.promises.rm(path.join(outputDir, entry.name));
|
|
482
550
|
}
|
|
483
551
|
}
|
|
484
552
|
|
|
485
|
-
// Landing-page variant swap. App-focused
|
|
553
|
+
// Landing-page variant swap. App-focused projects replace the SaaS
|
|
486
554
|
// landing with the app-marketing one before placeholder replacement
|
|
487
555
|
// runs (so the swapped files get their placeholders filled too). The
|
|
488
556
|
// _variants staging dir is always removed — it never ships.
|
|
489
|
-
if (
|
|
557
|
+
if (appLanding) {
|
|
490
558
|
for (const [variant, canonical] of APP_LANDING_SWAPS) {
|
|
491
559
|
const from = path.join(outputDir, variant);
|
|
492
560
|
const to = path.join(outputDir, canonical);
|
|
@@ -544,9 +612,11 @@ export async function createProject() {
|
|
|
544
612
|
|
|
545
613
|
console.log('');
|
|
546
614
|
|
|
547
|
-
// [1/5] Copy template (
|
|
615
|
+
// [1/5] Copy template (component-aware pruning)
|
|
548
616
|
console.log(green('[1/5]') + ' Copying template files...');
|
|
549
|
-
await copyTemplate(
|
|
617
|
+
await copyTemplate(
|
|
618
|
+
outputDir, details.components, details.appLanding
|
|
619
|
+
);
|
|
550
620
|
|
|
551
621
|
// [2/5] Replace placeholders
|
|
552
622
|
const files = await getAllFiles(outputDir);
|
|
@@ -579,6 +649,12 @@ export async function createProject() {
|
|
|
579
649
|
const newPath = path.join(dir, newName);
|
|
580
650
|
await fs.promises.rename(filePath, newPath);
|
|
581
651
|
}
|
|
652
|
+
|
|
653
|
+
// Restore the dot on gitignore files. They ship dot-less because
|
|
654
|
+
// npm strips any file named `.gitignore` from published packages.
|
|
655
|
+
if (basename === 'gitignore') {
|
|
656
|
+
await fs.promises.rename(filePath, path.join(dir, '.gitignore'));
|
|
657
|
+
}
|
|
582
658
|
}
|
|
583
659
|
|
|
584
660
|
// Path-segment renames (e.g. mobile/{{PROJECT_SLUG}}/)
|
|
@@ -591,7 +667,7 @@ export async function createProject() {
|
|
|
591
667
|
// primary color + first letter of the project name. A failure
|
|
592
668
|
// here is fatal (without these files, expo prebuild + the app
|
|
593
669
|
// splash both break), so we surface the error instead of swallowing.
|
|
594
|
-
if (details.
|
|
670
|
+
if (details.components.mobile) {
|
|
595
671
|
const mobileAssets = path.join(
|
|
596
672
|
outputDir, 'mobile', details.slug, 'assets'
|
|
597
673
|
);
|
|
@@ -616,7 +692,7 @@ export async function createProject() {
|
|
|
616
692
|
'seed.sh',
|
|
617
693
|
'server/knex.sh',
|
|
618
694
|
];
|
|
619
|
-
if (details.
|
|
695
|
+
if (details.components.mobile) {
|
|
620
696
|
scripts.push(
|
|
621
697
|
'scripts/bump-version.sh',
|
|
622
698
|
'scripts/prebuild.sh',
|
|
@@ -628,7 +704,7 @@ export async function createProject() {
|
|
|
628
704
|
'scripts/screenshots-capture.sh'
|
|
629
705
|
);
|
|
630
706
|
}
|
|
631
|
-
if (details.
|
|
707
|
+
if (details.components.server) {
|
|
632
708
|
scripts.push('misc/build-local.sh');
|
|
633
709
|
}
|
|
634
710
|
|
|
@@ -693,10 +769,10 @@ async function renameDirs(root, slug) {
|
|
|
693
769
|
}
|
|
694
770
|
|
|
695
771
|
function showNextSteps(details) {
|
|
696
|
-
const
|
|
772
|
+
const c = details.components;
|
|
697
773
|
console.log(chalk.bold(' Next steps:'));
|
|
698
774
|
console.log(` ${green('1.')} cd ${details.outputDir}`);
|
|
699
|
-
if (
|
|
775
|
+
if (c.server) {
|
|
700
776
|
console.log(` ${green('2.')} ./install.sh`);
|
|
701
777
|
console.log(` ${green('3.')} ./seed.sh`);
|
|
702
778
|
console.log(` ${green('4.')} ./dev.sh`);
|
|
@@ -707,16 +783,16 @@ function showNextSteps(details) {
|
|
|
707
783
|
console.log(` ${green('3.')} ./dev.sh`);
|
|
708
784
|
}
|
|
709
785
|
console.log('');
|
|
710
|
-
if (
|
|
786
|
+
if (c.adminUi) {
|
|
711
787
|
console.log(dim(` Admin panel: ${cyan(`http://localhost:${details.adminUiPort}`)}`));
|
|
712
788
|
}
|
|
713
|
-
if (
|
|
789
|
+
if (c.server) {
|
|
714
790
|
console.log(dim(` Admin API: ${cyan(`http://localhost:${details.apiPort}`)}`));
|
|
715
791
|
}
|
|
716
|
-
if (
|
|
792
|
+
if (c.site) {
|
|
717
793
|
console.log(dim(` Site: ${cyan(`http://localhost:${details.sitePort}`)}`));
|
|
718
794
|
}
|
|
719
|
-
if (
|
|
795
|
+
if (c.mobile) {
|
|
720
796
|
console.log(dim(` Mobile: cd mobile/${details.slug} && npm run ios | android | web`));
|
|
721
797
|
console.log(dim(` Store kit: mobile/${details.slug}/docs/ (listing, copy, screenshots) + CHANGELOG.md`));
|
|
722
798
|
}
|
package/package.json
CHANGED
package/template/CLAUDE.md
CHANGED
|
@@ -55,10 +55,11 @@ URLs:
|
|
|
55
55
|
└── .claude/settings.json Permission allowlist for routine commands
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Folders
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
package/template/README.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
65
|
+
every component combination.
|
|
66
66
|
|
|
67
67
|
## Multi-tenancy model
|
|
68
68
|
|
|
@@ -26,8 +26,7 @@ src/
|
|
|
26
26
|
│ ├── backup/ Two-phase S3 backup (premium)
|
|
27
27
|
│ ├── content/ Versioned catalog + HMAC-signed URLs
|
|
28
28
|
│ ├── iap/ react-native-iap wrapped lazily
|
|
29
|
-
│
|
|
30
|
-
│ └── health/ Apple Health + Health Connect unified
|
|
29
|
+
│ └── notifications/ Custom-native, cold-start tap dispatch
|
|
31
30
|
└── components/
|
|
32
31
|
├── AnimatedSplash.tsx
|
|
33
32
|
├── ToastConfig.tsx
|
|
@@ -95,6 +94,16 @@ hasCompletedOnboarding
|
|
|
95
94
|
Use `./scripts/prebuild.sh` (refuses --clean). To genuinely
|
|
96
95
|
reset, move customizations into `plugins/` first.
|
|
97
96
|
|
|
97
|
+
- **Dependency versions follow the Expo SDK.** Never bump
|
|
98
|
+
`react`, `react-native`, or any `expo-*` / `react-native-*`
|
|
99
|
+
native package by hand — run `npx expo install --check` (or
|
|
100
|
+
`--fix`) so everything stays on the SDK's bundled versions.
|
|
101
|
+
`typescript` is intentionally in `expo.install.exclude`
|
|
102
|
+
(package.json): the SDK wants TS 6 but `react-i18next@15`
|
|
103
|
+
peer-requires TS 5 — keep `~5.9.x` until react-i18next is
|
|
104
|
+
bumped to v17+ (which drags i18next to v26 and drops the
|
|
105
|
+
`compatibilityJSON: 'v3'` Hermes escape hatch in `src/i18n/`).
|
|
106
|
+
|
|
98
107
|
## Splash
|
|
99
108
|
|
|
100
109
|
`config.ts` exports `SPLASH_ANIMATED` (set at scaffold time). When
|