umpordez 1.3.2 → 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.
@@ -12,49 +12,51 @@
12
12
  "lint": "eslint src --ext .ts,.tsx",
13
13
  "typecheck": "tsc --noEmit"
14
14
  },
15
+ "expo": {
16
+ "install": {
17
+ "exclude": ["typescript"]
18
+ }
19
+ },
15
20
  "dependencies": {
16
21
  "@noble/hashes": "^2.2.0",
17
- "@react-native-async-storage/async-storage": "^2.2.0",
22
+ "@react-native-async-storage/async-storage": "2.2.0",
18
23
  "@react-navigation/bottom-tabs": "^7.7.3",
19
24
  "@react-navigation/native": "^7.1.19",
20
25
  "@react-navigation/native-stack": "^7.6.2",
21
- "expo": "~57.0.2",
26
+ "expo": "~57.0.4",
22
27
  "expo-apple-authentication": "~57.0.0",
23
- "expo-auth-session": "~7.0.10",
28
+ "expo-auth-session": "~57.0.2",
24
29
  "expo-build-properties": "~57.0.3",
25
30
  "expo-constants": "~57.0.3",
26
31
  "expo-file-system": "~57.0.0",
27
- "expo-linking": "~57.0.1",
32
+ "expo-linking": "~57.0.2",
28
33
  "expo-localization": "~57.0.0",
29
- "expo-share-intent": "^8.0.0",
30
34
  "expo-splash-screen": "~57.0.2",
31
35
  "expo-sqlite": "~57.0.0",
32
36
  "expo-status-bar": "~57.0.0",
33
37
  "expo-system-ui": "~57.0.0",
34
38
  "expo-web-browser": "~57.0.0",
35
39
  "i18next": "^23.16.5",
36
- "lucide-react-native": "^1.8.0",
37
- "react": "19.1.0",
38
- "react-native": "0.83.10",
40
+ "lucide-react-native": "^1.24.0",
41
+ "react": "19.2.3",
42
+ "react-native": "0.86.0",
39
43
  "react-i18next": "^15.1.1",
40
- "react-native-gesture-handler": "~3.0.2",
41
- "react-native-health": "^1.19.0",
42
- "react-native-health-connect": "^3.5.0",
43
- "react-native-iap": "^15.2.0",
44
+ "react-native-gesture-handler": "~2.32.0",
45
+ "react-native-iap": "^15.3.6",
44
46
  "react-native-nitro-modules": "^0.35.4",
45
- "react-native-reanimated": "~4.5.1",
46
- "react-native-safe-area-context": "^5.8.0",
47
- "react-native-screens": "~4.25.2",
48
- "react-native-svg": "15.12.1",
47
+ "react-native-reanimated": "4.5.0",
48
+ "react-native-safe-area-context": "~5.7.0",
49
+ "react-native-screens": "4.25.2",
50
+ "react-native-svg": "15.15.4",
49
51
  "react-native-toast-message": "^2.3.3",
50
- "react-native-worklets": "~0.10.1"
52
+ "react-native-worklets": "0.10.0"
51
53
  },
52
54
  "devDependencies": {
53
55
  "@stylistic/eslint-plugin": "^2.13.0",
54
- "@types/react": "~19.1.0",
55
- "babel-preset-expo": "~57.0.1",
56
+ "@types/react": "~19.2.2",
57
+ "babel-preset-expo": "~57.0.2",
56
58
  "eslint": "^9.20.1",
57
- "typescript": "~5.7.0",
59
+ "typescript": "~5.9.3",
58
60
  "typescript-eslint": "^8.24.0"
59
61
  }
60
62
  }
@@ -193,7 +193,11 @@ const RING_SIZE = width * 0.58;
193
193
 
194
194
  const s = StyleSheet.create({
195
195
  root: {
196
- ...StyleSheet.absoluteFillObject,
196
+ position: 'absolute',
197
+ top: 0,
198
+ left: 0,
199
+ right: 0,
200
+ bottom: 0,
197
201
  backgroundColor: BG,
198
202
  alignItems: 'center',
199
203
  justifyContent: 'center',
@@ -47,6 +47,15 @@ const GOOGLE_IOS_CLIENT_ID = 'TODO_GOOGLE_IOS_CLIENT_ID';
47
47
  // appears. Set at scaffold time via `umpordez create`.
48
48
  export const SPLASH_ANIMATED = {{SPLASH_ANIMATED}};
49
49
 
50
+ // Sign-in providers, chosen at scaffold time via `umpordez create`.
51
+ // Email+password always ships; these gate the extra buttons on
52
+ // AuthLanding. Flipping appleAuth back on also requires restoring
53
+ // `usesAppleSignIn` + the `expo-apple-authentication` plugin in
54
+ // app.json (the scaffold strips them when Apple is disabled so iOS
55
+ // builds don't demand the Sign in with Apple capability).
56
+ const GOOGLE_AUTH_ENABLED = {{AUTH_GOOGLE_ENABLED}};
57
+ const APPLE_AUTH_ENABLED = {{AUTH_APPLE_ENABLED}};
58
+
50
59
  if (__DEV__) {
51
60
  console.log(
52
61
  `[config] API: ${baseApiUrl} | prod=${isProdBuild} `
@@ -62,5 +71,7 @@ export default {
62
71
  version: APP_VERSION,
63
72
  googleClientId: GOOGLE_CLIENT_ID,
64
73
  googleIosClientId: GOOGLE_IOS_CLIENT_ID,
74
+ googleAuth: GOOGLE_AUTH_ENABLED,
75
+ appleAuth: APPLE_AUTH_ENABLED,
65
76
  scheme: '{{MOBILE_SCHEME}}'
66
77
  };
@@ -47,7 +47,7 @@ export default function AuthLandingScreen({ navigation }: Props) {
47
47
  })
48
48
  ]).start();
49
49
 
50
- if (Platform.OS === 'ios') {
50
+ if (Platform.OS === 'ios' && config.appleAuth) {
51
51
  AppleAuthentication.isAvailableAsync()
52
52
  .then(setAppleAvailable)
53
53
  .catch(() => setAppleAvailable(false));
@@ -279,58 +279,63 @@ export default function AuthLandingScreen({ navigation }: Props) {
279
279
  </TouchableOpacity>
280
280
  )}
281
281
 
282
- <TouchableOpacity
283
- style={[
284
- s.googleBtn,
285
- {
286
- backgroundColor: colors.card,
287
- borderColor: colors.border
288
- },
289
- busy && s.btnDisabled
290
- ]}
291
- onPress={handleGoogle}
292
- activeOpacity={0.88}
293
- disabled={busy}
294
- >
295
- {provider === 'google' ? (
296
- <ActivityIndicator color={colors.text} />
297
- ) : (
298
- <>
299
- <GoogleIcon />
300
- <Text
301
- style={[
302
- s.googleTxt,
303
- { color: colors.text }
304
- ]}
305
- >
306
- {t('auth.landing.google')}
307
- </Text>
308
- </>
309
- )}
310
- </TouchableOpacity>
311
-
312
- <View style={s.divRow}>
313
- <View
314
- style={[
315
- s.divLine,
316
- { backgroundColor: colors.border }
317
- ]}
318
- />
319
- <Text
282
+ {config.googleAuth && (
283
+ <TouchableOpacity
320
284
  style={[
321
- s.divLabel,
322
- { color: colors.textMuted }
285
+ s.googleBtn,
286
+ {
287
+ backgroundColor: colors.card,
288
+ borderColor: colors.border
289
+ },
290
+ busy && s.btnDisabled
323
291
  ]}
292
+ onPress={handleGoogle}
293
+ activeOpacity={0.88}
294
+ disabled={busy}
324
295
  >
325
- {t('auth.landing.divider')}
326
- </Text>
327
- <View
328
- style={[
329
- s.divLine,
330
- { backgroundColor: colors.border }
331
- ]}
332
- />
333
- </View>
296
+ {provider === 'google' ? (
297
+ <ActivityIndicator color={colors.text} />
298
+ ) : (
299
+ <>
300
+ <GoogleIcon />
301
+ <Text
302
+ style={[
303
+ s.googleTxt,
304
+ { color: colors.text }
305
+ ]}
306
+ >
307
+ {t('auth.landing.google')}
308
+ </Text>
309
+ </>
310
+ )}
311
+ </TouchableOpacity>
312
+ )}
313
+
314
+ {(config.googleAuth
315
+ || (Platform.OS === 'ios' && appleAvailable)) && (
316
+ <View style={s.divRow}>
317
+ <View
318
+ style={[
319
+ s.divLine,
320
+ { backgroundColor: colors.border }
321
+ ]}
322
+ />
323
+ <Text
324
+ style={[
325
+ s.divLabel,
326
+ { color: colors.textMuted }
327
+ ]}
328
+ >
329
+ {t('auth.landing.divider')}
330
+ </Text>
331
+ <View
332
+ style={[
333
+ s.divLine,
334
+ { backgroundColor: colors.border }
335
+ ]}
336
+ />
337
+ </View>
338
+ )}
334
339
 
335
340
  <TouchableOpacity
336
341
  style={[
@@ -39,6 +39,19 @@
39
39
  // plays the AnimatedSplash on boot or shows
40
40
  // up instantly (native splash hides on font
41
41
  // load). Consumed by mobile src/config.ts.
42
+ // {{AUTH_GOOGLE_ENABLED}} — "true"/"false" — Google sign-in button in
43
+ // the mobile app. Consumed by src/config.ts.
44
+ // {{AUTH_APPLE_ENABLED}} — "true"/"false" — Apple sign-in button in
45
+ // the mobile app. Consumed by src/config.ts.
46
+ // {{IOS_APPLE_SIGNIN_JSON}} — app.json fragment: `"usesAppleSignIn":
47
+ // true,` (with leading newline+indent) when
48
+ // Apple sign-in is on, empty when off.
49
+ // {{APPLE_AUTH_PLUGIN_JSON}} — app.json fragment: the
50
+ // `"expo-apple-authentication",` plugins
51
+ // entry when Apple sign-in is on, empty
52
+ // when off (the plugin adds the Sign in
53
+ // with Apple entitlement unconditionally,
54
+ // so it must not ship when disabled).
42
55
 
43
56
  // ─── Site landing variants ────────────────────────────────────────────────────
44
57
  // The public site ships two landing flavors. The SaaS landing (CTAs →
@@ -52,6 +65,101 @@
52
65
 
53
66
  export const SITE_VARIANT_DIR = 'ui/site/_variants';
54
67
 
68
+ // ─── Components ──────────────────────────────────────────────────────────────
69
+ // A project is any combination of four components, each picked with a
70
+ // yes/no at create time:
71
+ //
72
+ // server — Admin API + Site API (Express + PostgreSQL)
73
+ // adminUi — React admin webapp (requires server)
74
+ // site — Public site / landing page (EJS + i18n)
75
+ // mobile — Expo + React Native app
76
+ //
77
+ // Both create.mjs and update.mjs derive "which template paths ship"
78
+ // from these flags via the helpers below, so the two tools can never
79
+ // drift. The legacy preset names are kept as manifest labels (old
80
+ // projects only recorded a preset string).
81
+
82
+ export const LEGACY_PRESET_COMPONENTS = {
83
+ everything: { server: true, adminUi: true,
84
+ site: true, mobile: true },
85
+ 'web-only': { server: true, adminUi: true,
86
+ site: true, mobile: false },
87
+ 'mobile-and-site': { server: false, adminUi: false,
88
+ site: true, mobile: true },
89
+ 'mobile-and-server': { server: true, adminUi: false,
90
+ site: false, mobile: true },
91
+ 'just-mobile': { server: false, adminUi: false,
92
+ site: false, mobile: true }
93
+ };
94
+
95
+ /** Components for a manifest that only recorded a preset name. */
96
+ export function componentsForPreset(preset) {
97
+ return LEGACY_PRESET_COMPONENTS[preset]
98
+ ?? LEGACY_PRESET_COMPONENTS.everything;
99
+ }
100
+
101
+ /**
102
+ * Manifest label for a component combo. Combos matching a legacy
103
+ * preset keep its exact name (older CLI versions still understand
104
+ * the manifest); anything else is "custom".
105
+ */
106
+ export function presetNameForComponents(c) {
107
+ for (const [name, def] of
108
+ Object.entries(LEGACY_PRESET_COMPONENTS)) {
109
+ if (def.server === !!c.server
110
+ && def.adminUi === !!c.adminUi
111
+ && def.site === !!c.site
112
+ && def.mobile === !!c.mobile) {
113
+ return name;
114
+ }
115
+ }
116
+ return 'custom';
117
+ }
118
+
119
+ /** Top-level template dirs a component combo ships. */
120
+ export function includesForComponents(c) {
121
+ const includes = ['doc'];
122
+ if (c.server) {
123
+ includes.push('server', 'misc');
124
+ }
125
+ if (c.adminUi) {
126
+ includes.push('ui/admin');
127
+ }
128
+ if (c.site) {
129
+ includes.push('ui/site');
130
+ }
131
+ if (c.mobile) {
132
+ includes.push('mobile', 'scripts');
133
+ }
134
+ return includes;
135
+ }
136
+
137
+ /** Root-level template files a component combo ships. */
138
+ export function rootFilesForComponents(c) {
139
+ const files = ['CLAUDE.md', 'README.md', 'code.md',
140
+ 'install.sh', 'dev.sh'];
141
+ if (c.server) {
142
+ files.push('architecture.md', 'seed.sh');
143
+ }
144
+ if (c.server || c.site || c.adminUi) {
145
+ files.push('AGENTS.md');
146
+ }
147
+ return files;
148
+ }
149
+
150
+ /**
151
+ * Whether the public site ships the app-marketing landing (store
152
+ * buttons + download CTA) instead of the SaaS landing (signup CTA).
153
+ * New projects store the decision in details.appLanding; legacy
154
+ * manifests fall back to the preset that implied it.
155
+ */
156
+ export function wantsAppLanding(details, preset) {
157
+ if (typeof details?.appLanding === 'boolean') {
158
+ return details.appLanding;
159
+ }
160
+ return preset === 'mobile-and-site';
161
+ }
162
+
55
163
  // [variantPath, canonicalPath] — relative to the project root.
56
164
  export const APP_LANDING_SWAPS = [
57
165
  ['ui/site/_variants/app/home.ejs',
@@ -275,5 +383,24 @@ export function buildReplacements(details) {
275
383
  '{{JWT_TOKEN_SECRET}}': details.jwtTokenSecret ?? randomSecret(),
276
384
  '{{SPLASH_ANIMATED}}':
277
385
  details.splashStyle === 'animated' ? 'true' : 'false',
386
+ // Sign-in providers chosen at scaffold time. Email+password
387
+ // always ships; these gate the extra buttons in the mobile
388
+ // app. Default to enabled so legacy manifests (which never
389
+ // recorded a choice) keep their current behavior on update.
390
+ '{{AUTH_GOOGLE_ENABLED}}':
391
+ details.authGoogle === false ? 'false' : 'true',
392
+ '{{AUTH_APPLE_ENABLED}}':
393
+ details.authApple === false ? 'false' : 'true',
394
+ // app.json fragments — the expo-apple-authentication config
395
+ // plugin adds the Sign in with Apple entitlement
396
+ // unconditionally when listed, so when Apple is disabled the
397
+ // plugin entry (and usesAppleSignIn) must disappear from the
398
+ // JSON entirely, not just flip to false.
399
+ '{{IOS_APPLE_SIGNIN_JSON}}': details.authApple === false
400
+ ? ''
401
+ : '\n "usesAppleSignIn": true,',
402
+ '{{APPLE_AUTH_PLUGIN_JSON}}': details.authApple === false
403
+ ? ''
404
+ : '\n "expo-apple-authentication",',
278
405
  };
279
406
  }
package/update.mjs CHANGED
@@ -34,7 +34,11 @@ import { fileURLToPath } from 'node:url';
34
34
  import chalk from 'chalk';
35
35
  import {
36
36
  buildReplacements,
37
- APP_LANDING_SWAPS
37
+ APP_LANDING_SWAPS,
38
+ componentsForPreset,
39
+ includesForComponents,
40
+ rootFilesForComponents,
41
+ wantsAppLanding
38
42
  } from './template-variables.mjs';
39
43
  import { generateIcons, ICON_TARGETS } from './icons.mjs';
40
44
 
@@ -42,23 +46,15 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
42
46
  const TEMPLATE_DIR = path.join(__dirname, 'template');
43
47
  const MANIFEST_PATH = '.umpordez/manifest.json';
44
48
 
45
- // Mirror of the preset top-level includes mapping in create.mjs.
46
- // Kept in sync manually because importing from create.mjs would pull
47
- // inquirer + the prompt code into the update command unnecessarily.
48
- const PRESET_INCLUDES = {
49
- everything: ['server', 'ui/admin', 'ui/site', 'mobile', 'misc',
50
- 'scripts', 'doc'],
51
- 'web-only': ['server', 'ui/admin', 'ui/site', 'misc', 'doc'],
52
- 'mobile-and-site': ['mobile', 'ui/site', 'scripts', 'doc'],
53
- 'mobile-and-server': ['server', 'mobile', 'misc', 'scripts',
54
- 'doc'],
55
- 'just-mobile': ['mobile', 'scripts', 'doc']
56
- };
57
-
58
- // Presets that use the app-marketing landing instead of the SaaS one.
59
- // Mirror of create.mjs's `appLanding` preset flag — kept in sync
60
- // manually (see PRESET_INCLUDES note above).
61
- const APP_LANDING_PRESETS = new Set(['mobile-and-site']);
49
+ // Which components a project shipped. New manifests record the
50
+ // components object in details; legacy ones only stored a preset
51
+ // name map it through the shared table in template-variables.mjs.
52
+ function componentsFromManifest(manifest) {
53
+ if (manifest.details?.components) {
54
+ return manifest.details.components;
55
+ }
56
+ return componentsForPreset(manifest.preset);
57
+ }
62
58
 
63
59
  const green = chalk.hex('#02e027');
64
60
  const cyan = chalk.hex('#00fff9');
@@ -153,30 +149,26 @@ function applyPathPlaceholders(p, slug) {
153
149
  }
154
150
 
155
151
  /**
156
- * Walk the template, resolve each file's contents (post-replacement)
157
- * + final relative path. Returns a map of relative-path → buffer.
158
- *
159
- * Skips files outside the project's preset by checking against the
160
- * project's existing top-level layout — we only want to update what
161
- * the project actually shipped.
162
- */
163
- /**
164
- * Whether a relative path belongs to the preset. Same prune rules
165
- * as create.mjs's copyTemplate, expressed as a predicate so we can
166
- * evaluate per-file instead of pruning a directory.
152
+ * Whether a relative path belongs to the project's component combo.
153
+ * Same prune rules as create.mjs's copyTemplate, expressed as a
154
+ * predicate so we can evaluate per-file instead of pruning a
155
+ * directory.
167
156
  */
168
- // Top-level directories that ship with every preset (project config
157
+ // Top-level dirs/files that ship with every combo (project config
169
158
  // like .claude/ that doesn't belong to any single sub-app). Mirrors
170
- // the ALWAYS_KEEP set in create.mjs.
171
- const ALWAYS_IN_PRESET = new Set(['.claude']);
172
-
173
- function isPathInPreset(rel, preset) {
174
- const includes = PRESET_INCLUDES[preset] ?? PRESET_INCLUDES.everything;
159
+ // the ALWAYS_KEEP sets in create.mjs. Note: rel arrives post
160
+ // path-placeholder pass, so the dot-less `gitignore` the template
161
+ // ships is already `.gitignore` here.
162
+ const ALWAYS_IN_PROJECT = new Set(['.claude']);
163
+ const ALWAYS_ROOT_FILES = new Set(['.gitignore', '.editorconfig']);
164
+
165
+ function isPathInComponents(rel, components) {
166
+ const includes = includesForComponents(components);
175
167
  const top = rel.split('/')[0];
176
- if (ALWAYS_IN_PRESET.has(top)) {
168
+ if (ALWAYS_IN_PROJECT.has(top)) {
177
169
  return true;
178
170
  }
179
- // ui is special: only the chosen sub-apps live in the preset.
171
+ // ui is special: only the chosen sub-apps ship.
180
172
  if (top === 'ui') {
181
173
  const sub = rel.split('/')[1];
182
174
  return includes.includes(`ui/${sub}`);
@@ -184,22 +176,34 @@ function isPathInPreset(rel, preset) {
184
176
  if (includes.includes(top)) {
185
177
  return true;
186
178
  }
187
- // Top-level files (CLAUDE.md, dev.sh, etc) let create.mjs's
188
- // root-files filter decide. For update we ship them all and let
189
- // the manifest comparison do the right thing per file.
190
- return rel.indexOf('/') === -1;
179
+ // Top-level files: same filter create.mjs applies at scaffold
180
+ // time, so update never re-adds a root file the combo doesn't
181
+ // ship (e.g. seed.sh on a project without the server).
182
+ if (rel.indexOf('/') === -1) {
183
+ return ALWAYS_ROOT_FILES.has(rel)
184
+ || rootFilesForComponents(components).includes(rel);
185
+ }
186
+ return false;
191
187
  }
192
188
 
193
- async function loadTemplate(details, preset) {
189
+ /**
190
+ * Walk the template, resolve each file's contents (post-replacement)
191
+ * + final relative path. Returns a map of relative-path → buffer.
192
+ * Skips files outside the project's component combo — we only want
193
+ * to update what the project actually shipped.
194
+ */
195
+
196
+ async function loadTemplate(details, manifest) {
194
197
  const replacements = buildReplacements(details);
195
198
  const files = await getAllFiles(TEMPLATE_DIR);
196
199
  const out = new Map();
200
+ const components = componentsFromManifest(manifest);
197
201
 
198
202
  for (const abs of files) {
199
203
  const relRaw = path.relative(TEMPLATE_DIR, abs);
200
204
  const rel = applyPathPlaceholders(relRaw, details.slug);
201
205
 
202
- if (preset && !isPathInPreset(rel, preset)) {
206
+ if (!isPathInComponents(rel, components)) {
203
207
  continue;
204
208
  }
205
209
 
@@ -213,13 +217,13 @@ async function loadTemplate(details, preset) {
213
217
  }
214
218
 
215
219
  // Landing-page variant resolution — mirror create.mjs's copyTemplate
216
- // swap. For app-landing presets the canonical landing files are
220
+ // swap. For app-landing projects the canonical landing files are
217
221
  // sourced from ui/site/_variants/app/ so `umpordez update` compares
218
222
  // against the SAME flavor the project was scaffolded with (otherwise
219
223
  // it would try to push the SaaS landing over an app project). The
220
- // _variants/ staging files never ship, so drop them from the map for
221
- // every preset.
222
- if (APP_LANDING_PRESETS.has(preset)) {
224
+ // _variants/ staging files never ship, so drop them from the map
225
+ // in every case.
226
+ if (wantsAppLanding(details, manifest.preset)) {
223
227
  for (const [variant, canonical] of APP_LANDING_SWAPS) {
224
228
  if (out.has(variant)) {
225
229
  out.set(canonical, out.get(variant));
@@ -366,7 +370,7 @@ export async function updateProject(projectDirArg, opts = {}) {
366
370
  ));
367
371
  console.log('');
368
372
 
369
- const template = await loadTemplate(details, manifest.preset);
373
+ const template = await loadTemplate(details, manifest);
370
374
  const newHashes = {};
371
375
 
372
376
  let acceptAll = !!opts.yes;