@expokit/cli 0.4.4 → 0.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.
Files changed (239) hide show
  1. package/README.md +30 -3
  2. package/bin/expo-kit.js +749 -288
  3. package/bin/lib.mjs +310 -3
  4. package/package.json +1 -1
  5. package/registry/_docs/action-sheet.json +1 -1
  6. package/registry/_docs/alert.json +6 -0
  7. package/registry/_docs/bottom-sheet.json +6 -0
  8. package/registry/_docs/bullet.json +6 -0
  9. package/registry/_docs/dialog.json +1 -1
  10. package/registry/_docs/drawer.json +3 -3
  11. package/registry/_docs/glass-search-bar.json +1 -1
  12. package/registry/_docs/grain.json +6 -1
  13. package/registry/_docs/isometric-bar.json +6 -0
  14. package/registry/_docs/liquidglass-tab.json +1 -1
  15. package/registry/_docs/password-input.json +1 -1
  16. package/registry/_docs/pixel-bar.json +6 -0
  17. package/registry/_docs/polar-area.json +6 -0
  18. package/registry/_docs/qr-code-button.json +9 -3
  19. package/registry/_docs/scratch-card.json +7 -1
  20. package/registry/_docs/search-bar.json +1 -1
  21. package/registry/_docs/select.json +2 -2
  22. package/registry/_docs/slide-to-confirm.json +2 -2
  23. package/registry/_docs/spinner.json +1 -1
  24. package/registry/_docs/stateful-button.json +2 -2
  25. package/registry/_docs/swipe-deck.json +1 -1
  26. package/registry/_docs/time-picker.json +2 -2
  27. package/registry/_docs/timeline-chart.json +6 -0
  28. package/registry/_docs/toast.json +15 -3
  29. package/registry/_docs/treemap.json +6 -0
  30. package/registry/_docs/waterfall.json +6 -0
  31. package/registry/accordion/files/src/components/ui/accordion.tsx +6 -4
  32. package/registry/accordion/files/src/lib/kit.tsx +363 -11
  33. package/registry/accordion.json +1 -0
  34. package/registry/action-sheet/files/src/components/ui/action-sheet.tsx +43 -48
  35. package/registry/action-sheet/files/src/lib/kit.tsx +363 -11
  36. package/registry/action-sheet.json +1 -0
  37. package/registry/alert/files/src/components/ui/alert.tsx +19 -12
  38. package/registry/alert/files/src/lib/kit.tsx +363 -11
  39. package/registry/alert.json +3 -4
  40. package/registry/assistant-app.json +1 -0
  41. package/registry/avatar/files/src/components/ui/avatar.tsx +84 -66
  42. package/registry/avatar/files/src/lib/kit.tsx +363 -11
  43. package/registry/avatar.json +1 -0
  44. package/registry/badge/files/src/components/ui/badge.tsx +34 -34
  45. package/registry/badge/files/src/lib/kit.tsx +363 -11
  46. package/registry/badge.json +1 -0
  47. package/registry/bottom-nav/files/src/components/ui/bottom-nav.tsx +28 -27
  48. package/registry/bottom-nav/files/src/lib/kit.tsx +363 -11
  49. package/registry/bottom-nav.json +5 -0
  50. package/registry/bottom-sheet/files/src/components/ui/bottom-sheet.tsx +353 -289
  51. package/registry/bottom-sheet/files/src/lib/kit.tsx +363 -11
  52. package/registry/bottom-sheet.json +5 -1
  53. package/registry/bullet.json +1 -0
  54. package/registry/button/files/src/components/ui/button.tsx +18 -16
  55. package/registry/button/files/src/lib/kit.tsx +363 -11
  56. package/registry/button-3d/files/src/components/ui/button-3d.tsx +2 -2
  57. package/registry/button-3d/files/src/lib/kit.tsx +363 -11
  58. package/registry/button-3d.json +1 -0
  59. package/registry/button.json +1 -0
  60. package/registry/calendar/files/src/components/ui/calendar.tsx +74 -76
  61. package/registry/calendar/files/src/lib/kit.tsx +363 -11
  62. package/registry/calendar.json +1 -0
  63. package/registry/card/files/src/components/ui/card.tsx +3 -3
  64. package/registry/card/files/src/lib/kit.tsx +363 -11
  65. package/registry/card-stack.json +1 -0
  66. package/registry/card.json +1 -0
  67. package/registry/carousel.json +1 -0
  68. package/registry/chat.json +7 -0
  69. package/registry/checkbox/files/src/components/ui/checkbox.tsx +4 -4
  70. package/registry/checkbox/files/src/lib/kit.tsx +363 -11
  71. package/registry/checkbox.json +1 -0
  72. package/registry/checkout.json +1 -0
  73. package/registry/chip-group/files/src/components/ui/chip-group.tsx +22 -22
  74. package/registry/chip-group/files/src/lib/kit.tsx +363 -11
  75. package/registry/chip-group.json +1 -0
  76. package/registry/confetti/files/src/components/ui/confetti.tsx +15 -14
  77. package/registry/confetti.json +3 -6
  78. package/registry/coupon/files/src/components/ui/coupon.tsx +42 -42
  79. package/registry/coupon/files/src/components/ui/flip-card.tsx +6 -5
  80. package/registry/coupon/files/src/components/ui/tilt-card.tsx +76 -91
  81. package/registry/coupon/files/src/lib/kit.tsx +363 -11
  82. package/registry/coupon/files/src/lib/ticket-shape.tsx +54 -44
  83. package/registry/coupon.json +1 -0
  84. package/registry/cube-pager.json +1 -0
  85. package/registry/dashboard.json +1 -0
  86. package/registry/date-picker/files/src/components/ui/calendar.tsx +74 -76
  87. package/registry/date-picker/files/src/components/ui/date-picker.tsx +55 -56
  88. package/registry/date-picker/files/src/lib/kit.tsx +363 -11
  89. package/registry/date-picker.json +1 -0
  90. package/registry/dialog/files/src/components/ui/dialog.tsx +60 -56
  91. package/registry/dialog/files/src/lib/kit.tsx +363 -11
  92. package/registry/dialog.json +1 -0
  93. package/registry/donut-shop.json +1 -0
  94. package/registry/drawer/files/src/components/ui/drawer.tsx +73 -69
  95. package/registry/drawer/files/src/lib/kit.tsx +363 -11
  96. package/registry/drawer.json +1 -0
  97. package/registry/fab.json +1 -0
  98. package/registry/fintech-app.json +1 -0
  99. package/registry/flip-calendar/files/src/components/ui/flip-calendar.tsx +92 -86
  100. package/registry/flip-calendar/files/src/lib/kit.tsx +363 -11
  101. package/registry/flip-calendar.json +7 -0
  102. package/registry/flip-card/files/src/components/ui/flip-card.tsx +6 -5
  103. package/registry/flip-card/files/src/lib/kit.tsx +363 -11
  104. package/registry/flip-card.json +1 -0
  105. package/registry/floating-input/files/src/components/ui/floating-input.tsx +33 -33
  106. package/registry/floating-input/files/src/lib/kit.tsx +363 -11
  107. package/registry/floating-input.json +1 -0
  108. package/registry/food-app.json +1 -0
  109. package/registry/gift-card.json +1 -0
  110. package/registry/glass-button.json +5 -0
  111. package/registry/glass-nav-bar.json +1 -0
  112. package/registry/glass-search-bar.json +1 -0
  113. package/registry/glass-segmented.json +1 -0
  114. package/registry/glass-sheet.json +1 -0
  115. package/registry/glass-tab-bar.json +5 -0
  116. package/registry/glass.json +1 -0
  117. package/registry/grain/files/src/components/ui/grain.tsx +16 -14
  118. package/registry/grain.json +8 -6
  119. package/registry/inbox.json +1 -0
  120. package/registry/isometric-bar.json +1 -0
  121. package/registry/kit/files/src/lib/kit.tsx +363 -11
  122. package/registry/kit.json +1 -0
  123. package/registry/liquidglass-tab/files/src/app/_layout.tsx +0 -1
  124. package/registry/liquidglass-tab/files/src/components/GlassTabBar.tsx +23 -9
  125. package/registry/liquidglass-tab/files/src/components/TabScreen.tsx +2 -2
  126. package/registry/liquidglass-tab/files/src/lib/tab-icons.ts +1 -1
  127. package/registry/liquidglass-tab.json +1 -0
  128. package/registry/list-item/files/src/components/ui/list-item.tsx +42 -42
  129. package/registry/list-item/files/src/lib/kit.tsx +363 -11
  130. package/registry/list-item.json +1 -0
  131. package/registry/loyalty-card.json +1 -0
  132. package/registry/morphing-button-group.json +1 -0
  133. package/registry/number-ticker.json +1 -0
  134. package/registry/offers.json +1 -0
  135. package/registry/onboarding.json +1 -0
  136. package/registry/orb-reveal.json +1 -0
  137. package/registry/otp-input/files/src/components/ui/otp-input.tsx +48 -29
  138. package/registry/otp-input/files/src/lib/kit.tsx +363 -11
  139. package/registry/otp-input.json +1 -0
  140. package/registry/parallax-layers.json +1 -0
  141. package/registry/parallax-scroll.json +1 -0
  142. package/registry/password-input/files/src/components/ui/floating-input.tsx +33 -33
  143. package/registry/password-input/files/src/components/ui/password-input.tsx +29 -25
  144. package/registry/password-input/files/src/lib/kit.tsx +363 -11
  145. package/registry/password-input.json +1 -0
  146. package/registry/payment-card/files/src/components/ui/flip-card.tsx +6 -5
  147. package/registry/payment-card/files/src/components/ui/payment-card.tsx +17 -17
  148. package/registry/payment-card/files/src/components/ui/tilt-card.tsx +76 -91
  149. package/registry/payment-card/files/src/lib/kit.tsx +363 -11
  150. package/registry/payment-card.json +1 -0
  151. package/registry/paywall.json +1 -0
  152. package/registry/pixel-bar.json +1 -0
  153. package/registry/polar-area.json +1 -0
  154. package/registry/profile.json +1 -0
  155. package/registry/progress/files/src/components/ui/progress.tsx +29 -29
  156. package/registry/progress/files/src/lib/kit.tsx +363 -11
  157. package/registry/progress-steps/files/src/components/ui/progress-steps.tsx +20 -20
  158. package/registry/progress-steps/files/src/lib/kit.tsx +363 -11
  159. package/registry/progress-steps.json +1 -0
  160. package/registry/progress.json +1 -0
  161. package/registry/qr-code-button.json +1 -0
  162. package/registry/radio-group/files/src/components/ui/radio-group.tsx +19 -19
  163. package/registry/radio-group/files/src/lib/kit.tsx +363 -11
  164. package/registry/radio-group.json +1 -0
  165. package/registry/rating.json +1 -0
  166. package/registry/ring-carousel.json +1 -0
  167. package/registry/ruler-time-picker/files/src/components/ui/ruler-time-picker.tsx +98 -82
  168. package/registry/ruler-time-picker/files/src/lib/kit.tsx +363 -11
  169. package/registry/ruler-time-picker.json +7 -0
  170. package/registry/scratch-card.json +1 -0
  171. package/registry/search-bar/files/src/components/ui/search-bar.tsx +25 -21
  172. package/registry/search-bar/files/src/lib/kit.tsx +363 -11
  173. package/registry/search-bar.json +1 -0
  174. package/registry/search-results.json +4 -0
  175. package/registry/segmented-control/files/src/components/ui/segmented-control.tsx +8 -7
  176. package/registry/segmented-control/files/src/lib/kit.tsx +363 -11
  177. package/registry/segmented-control.json +1 -0
  178. package/registry/select/files/src/components/ui/select.tsx +93 -88
  179. package/registry/select/files/src/lib/kit.tsx +363 -11
  180. package/registry/select.json +6 -0
  181. package/registry/settings.json +1 -0
  182. package/registry/sign-in.json +1 -0
  183. package/registry/skeleton/files/src/components/ui/skeleton.tsx +149 -80
  184. package/registry/skeleton/files/src/lib/kit.tsx +363 -11
  185. package/registry/skeleton.json +1 -0
  186. package/registry/slide-to-confirm.json +1 -0
  187. package/registry/slider/files/src/components/ui/slider.tsx +103 -96
  188. package/registry/slider/files/src/lib/kit.tsx +363 -11
  189. package/registry/slider.json +5 -0
  190. package/registry/spin-wheel.json +1 -0
  191. package/registry/spinner/files/src/components/ui/spinner.tsx +39 -29
  192. package/registry/spinner/files/src/lib/kit.tsx +363 -11
  193. package/registry/spinner.json +6 -0
  194. package/registry/stateful-button/files/src/components/ui/stateful-button.tsx +39 -32
  195. package/registry/stateful-button/files/src/lib/kit.tsx +363 -11
  196. package/registry/stateful-button.json +5 -0
  197. package/registry/stepper/files/src/components/ui/stepper.tsx +38 -36
  198. package/registry/stepper/files/src/lib/kit.tsx +363 -11
  199. package/registry/stepper.json +1 -0
  200. package/registry/swipe-deck.json +1 -0
  201. package/registry/swipeable-row.json +4 -0
  202. package/registry/switch/files/src/components/ui/switch.tsx +39 -42
  203. package/registry/switch/files/src/lib/kit.tsx +363 -11
  204. package/registry/switch.json +1 -0
  205. package/registry/tabs/files/src/components/ui/tabs.tsx +59 -62
  206. package/registry/tabs/files/src/lib/kit.tsx +363 -11
  207. package/registry/tabs.json +1 -0
  208. package/registry/text-animation/files/src/components/ui/text-animation.tsx +55 -42
  209. package/registry/text-animation/files/src/lib/kit.tsx +363 -11
  210. package/registry/text-animation.json +7 -0
  211. package/registry/textarea/files/src/components/ui/textarea.tsx +10 -10
  212. package/registry/textarea/files/src/lib/kit.tsx +363 -11
  213. package/registry/textarea.json +1 -0
  214. package/registry/ticket-card.json +1 -0
  215. package/registry/ticket-shape/files/src/lib/kit.tsx +363 -11
  216. package/registry/ticket-shape/files/src/lib/ticket-shape.tsx +54 -44
  217. package/registry/ticket-shape.json +1 -0
  218. package/registry/tilt-card/files/src/components/ui/tilt-card.tsx +76 -91
  219. package/registry/tilt-card/files/src/lib/kit.tsx +363 -11
  220. package/registry/tilt-card.json +1 -0
  221. package/registry/time-picker/files/src/components/ui/time-picker.tsx +122 -119
  222. package/registry/time-picker/files/src/lib/kit.tsx +363 -11
  223. package/registry/time-picker.json +1 -0
  224. package/registry/timeline/files/src/components/ui/avatar.tsx +84 -66
  225. package/registry/timeline/files/src/components/ui/timeline.tsx +44 -41
  226. package/registry/timeline/files/src/lib/kit.tsx +363 -11
  227. package/registry/timeline-chart.json +1 -0
  228. package/registry/timeline.json +1 -0
  229. package/registry/toast/files/src/components/ui/toast.tsx +541 -463
  230. package/registry/toast/files/src/lib/kit.tsx +363 -11
  231. package/registry/toast.json +7 -5
  232. package/registry/treemap.json +1 -0
  233. package/registry/waterfall.json +1 -0
  234. package/registry/wheel-list.json +1 -0
  235. package/schema/components.schema.json +1 -0
  236. package/registry/alert/files/src/components/ui/button.tsx +0 -290
  237. package/registry/confetti/files/src/lib/kit.tsx +0 -320
  238. package/registry/grain/files/src/lib/kit.tsx +0 -320
  239. package/registry/toast/files/src/components/ui/button.tsx +0 -290
package/bin/expo-kit.js CHANGED
@@ -20,23 +20,37 @@ import {
20
20
  CliError,
21
21
  DEFAULT_SITE_URL,
22
22
  addTsconfigPathAlias,
23
+ aliasRootFromTsconfig,
24
+ aliasTargetForDir,
23
25
  canSendToken,
26
+ caseClash,
27
+ contentHash,
24
28
  envWithoutToken,
29
+ fileStatus,
25
30
  hasJsoncExtras,
26
31
  isInsideDir,
27
32
  isSafePackageSpec,
28
33
  isSafeTabName,
29
34
  isValidItemName,
35
+ kitThemeSource,
30
36
  kitVersion,
37
+ looksLikeKitFile,
31
38
  mergeDependencies,
39
+ missingPackages,
40
+ normalizeInstalled,
32
41
  parseJsonc,
33
42
  parseSiteUrl,
34
43
  retryDelayMs,
44
+ recordedHash,
35
45
  retryHint,
46
+ rewriteKitImports,
47
+ suggestItemNames,
36
48
  tabComponentName,
37
49
  telemetryEnabled,
50
+ themeOptions,
38
51
  needsDevBuild,
39
52
  pickMainExport,
53
+ unifiedDiff,
40
54
  } from './lib.mjs';
41
55
 
42
56
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -61,12 +75,14 @@ const DEFAULT_ALIASES = {
61
75
  const SRC_PATHS = {
62
76
  components: 'src/components',
63
77
  lib: 'src/lib',
78
+ hooks: 'src/hooks',
64
79
  app: 'src/app',
65
80
  };
66
81
 
67
82
  const ROOT_PATHS = {
68
83
  components: 'components',
69
84
  lib: 'lib',
85
+ hooks: 'hooks',
70
86
  app: 'app',
71
87
  };
72
88
 
@@ -111,7 +127,12 @@ function warn(msg) {
111
127
  console.warn(`\x1b[33mexpo-kit\x1b[0m ${msg}`);
112
128
  }
113
129
 
130
+ /** Stops the current command with a user-facing message (main prints it and exits 1; a batch `add` records it per item). */
114
131
  function fail(msg) {
132
+ throw new CliError(msg);
133
+ }
134
+
135
+ function die(msg) {
115
136
  console.error(`\x1b[31mexpo-kit\x1b[0m ${msg}`);
116
137
  process.exit(1);
117
138
  }
@@ -163,13 +184,30 @@ function planTsconfigAlias(projectRoot, aliasTarget) {
163
184
  }
164
185
  }
165
186
 
187
+ /** Flags that take a value (`--dir x` or `--dir=x`), with the example shown when it's missing. */
188
+ const VALUE_FLAGS = {
189
+ dir: '--dir components/kit',
190
+ brand: '--brand "#6366f1"',
191
+ font: '--font Inter',
192
+ radius: '--radius 14',
193
+ scheme: '--scheme dark',
194
+ };
195
+
166
196
  function parseFlags(argv) {
167
- const flags = { dryRun: false, force: false };
197
+ const flags = { dryRun: false, force: false, format: true, dir: null, yes: false };
168
198
  const positional = [];
169
- for (const arg of argv) {
199
+ for (let i = 0; i < argv.length; i += 1) {
200
+ const arg = argv[i];
201
+ const name = /^--([a-z]+)(=|$)/.exec(arg)?.[1];
170
202
  if (arg === '--dry-run') flags.dryRun = true;
171
203
  else if (arg === '--force') flags.force = true;
172
- else if (arg === '--list' || arg === '-l') positional.push('--list');
204
+ else if (arg === '--no-format') flags.format = false;
205
+ else if (arg === '--yes' || arg === '-y') flags.yes = true;
206
+ else if (name && Object.hasOwn(VALUE_FLAGS, name)) {
207
+ const value = arg === `--${name}` ? argv[(i += 1)] : arg.slice(name.length + 3);
208
+ if (!value || value.startsWith('--')) fail(`--${name} needs a value, e.g. ${VALUE_FLAGS[name]}`);
209
+ flags[name] = value;
210
+ } else if (arg === '--list' || arg === '-l') positional.push('--list');
173
211
  else positional.push(arg);
174
212
  }
175
213
  return { flags, positional };
@@ -215,6 +253,7 @@ function loadConfig(projectRoot) {
215
253
  return { ...defaults, configPath };
216
254
  }
217
255
  const user = readJsonFile(configPath, 'components.json');
256
+ if (!user || typeof user !== 'object' || Array.isArray(user)) throw new CliError('components.json must contain a JSON object. Fix it and re-run.');
218
257
  const paths = { ...defaults.paths, ...user.paths };
219
258
  // Every write goes to path.join(projectRoot, paths.*): a "../" from a cloned repo would land outside the app.
220
259
  for (const [key, value] of Object.entries(paths)) {
@@ -223,27 +262,35 @@ function loadConfig(projectRoot) {
223
262
  throw new CliError(`components.json paths.${key} (${JSON.stringify(value)}) must be a folder inside the project. Fix it and re-run.`);
224
263
  }
225
264
  }
265
+ const aliases = { ...defaults.aliases, ...user.aliases };
266
+ // Aliases are written into the copied files' import lines: a quote or space would be code, not a path.
267
+ for (const [key, value] of Object.entries(aliases)) {
268
+ if (typeof value !== 'string' || !/^[@~#$]?[A-Za-z0-9_.\/-]*$/.test(value) || value.length > 100) {
269
+ throw new CliError(`components.json aliases.${key} (${JSON.stringify(value)}) must be an import alias like "@/components". Fix it and re-run.`);
270
+ }
271
+ }
226
272
  return {
227
273
  ...defaults,
228
274
  ...user,
229
- aliases: { ...defaults.aliases, ...user.aliases },
275
+ aliases,
230
276
  paths,
231
- installed: { ...user.installed },
277
+ installed: normalizeInstalled(user.installed),
278
+ files: cleanFileHashes(user.files),
232
279
  configPath,
233
280
  };
234
281
  }
235
282
 
283
+ /** components.json `files` (project path → sha256 of the file as the CLI last wrote it), malformed entries dropped. */
284
+ function cleanFileHashes(files) {
285
+ if (!files || typeof files !== 'object' || Array.isArray(files)) return {};
286
+ return Object.fromEntries(Object.keys(files).flatMap((rel) => (recordedHash(files, rel) ? [[rel, files[rel]]] : [])));
287
+ }
288
+
236
289
  function saveConfig(projectRoot, config) {
237
290
  const { configPath, ...data } = config;
238
291
  fs.writeFileSync(configPath, `${JSON.stringify(data, null, 2)}\n`);
239
292
  }
240
293
 
241
- function markTemplateInstalled(projectRoot, config, resolved) {
242
- config.installed ??= {};
243
- config.installed[resolved] = getPackageVersion();
244
- saveConfig(projectRoot, config);
245
- }
246
-
247
294
  function detectInstalledTemplates(projectRoot, config) {
248
295
  const installed = new Set(Object.keys(config.installed ?? {}));
249
296
 
@@ -258,43 +305,57 @@ function detectInstalledTemplates(projectRoot, config) {
258
305
  return [...installed];
259
306
  }
260
307
 
261
- function registryPathToProjectPath(registryRel, config, projectRoot) {
262
- const [folder, ...rest] = registryRel.split('/');
263
- const folderDest = {
308
+ /** Project folder (relative) each registry src/<folder> lands in. */
309
+ function folderDest(config, folder) {
310
+ const dest = {
264
311
  app: config.paths.app ?? 'src/app',
265
312
  components: config.paths.components ?? 'src/components',
266
313
  lib: config.paths.lib ?? 'src/lib',
267
314
  constants: config.paths.constants ?? 'src/constants',
268
315
  hooks: config.paths.hooks ?? 'src/hooks',
269
316
  };
270
- return path.join(projectRoot, folderDest[folder] ?? `src/${folder}`, ...rest);
317
+ return dest[folder] ?? `src/${folder}`;
271
318
  }
272
319
 
273
- function copyRegistryFiles(filesRoot, projectRoot, config, registryPaths, options) {
274
- for (const registryRel of registryPaths) {
275
- const srcPath = path.join(filesRoot, 'src', registryRel);
276
- const destPath = registryPathToProjectPath(registryRel, config, projectRoot);
277
- const rel = path.relative(projectRoot, destPath);
320
+ function registryPathToProjectPath(registryRel, config, projectRoot) {
321
+ const [folder, ...rest] = registryRel.split('/');
322
+ return path.join(projectRoot, folderDest(config, folder), ...rest);
323
+ }
278
324
 
279
- if (!fs.existsSync(srcPath)) {
280
- warn(`Registry file missing: ${registryRel}`);
281
- continue;
282
- }
325
+ /** A registry file as it lands in this project: its @/components and @/lib imports follow components.json aliases. */
326
+ function registryText(srcPath, config) {
327
+ const text = fs.readFileSync(srcPath, 'utf8');
328
+ return /\.(tsx?|jsx?)$/.test(srcPath) ? rewriteKitImports(text, config.aliases) : text;
329
+ }
283
330
 
284
- if (options.dryRun) {
285
- log(` would write ${rel}`);
286
- continue;
287
- }
331
+ let aliasIndex = null;
288
332
 
289
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
290
- fs.writeFileSync(destPath, fs.readFileSync(srcPath, 'utf8'));
291
- log(` + ${rel}`);
333
+ /** alias → item name: the built-in ones plus every item's `aliases` (grain-gradient → grain). */
334
+ function localAliases() {
335
+ if (aliasIndex) return aliasIndex;
336
+ aliasIndex = new Map(Object.entries(ALIASES));
337
+ for (const file of fs.readdirSync(REGISTRY_DIR).filter((f) => f.endsWith('.json'))) {
338
+ const meta = tryReadJsonFile(path.join(REGISTRY_DIR, file));
339
+ for (const alias of Array.isArray(meta?.aliases) ? meta.aliases : []) {
340
+ if (isValidItemName(alias) && !aliasIndex.has(alias)) aliasIndex.set(alias, meta.name);
341
+ }
292
342
  }
343
+ return aliasIndex;
293
344
  }
294
345
 
295
346
  function resolveRegistryName(name) {
296
- const key = name.toLowerCase();
297
- return ALIASES[key] ?? key;
347
+ const key = String(name).toLowerCase();
348
+ if (isValidItemName(key) && readMeta(key)) return key;
349
+ return localAliases().get(key) ?? key;
350
+ }
351
+
352
+ /** " Did you mean: button?" for a name this CLI doesn't know, or "". */
353
+ function didYouMean(name) {
354
+ const items = fs.readdirSync(REGISTRY_DIR).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -'.json'.length));
355
+ const aliases = localAliases();
356
+ const close = suggestItemNames(name, [...items, ...aliases.keys()]).map((n) => aliases.get(n) ?? n);
357
+ const unique = [...new Set(close)];
358
+ return unique.length ? ` Did you mean: ${unique.join(', ')}?` : '';
298
359
  }
299
360
 
300
361
  function readMeta(resolved) {
@@ -317,10 +378,15 @@ async function loadRegistryItem(name) {
317
378
  const resolved = resolveRegistryName(name);
318
379
  const meta = isValidItemName(resolved) ? readMeta(resolved) : null;
319
380
  if (!meta) {
320
- if (!isValidItemName(resolved)) fail(`Unknown item "${name}". Run: ${RUN} add --list`);
381
+ if (!isValidItemName(resolved)) fail(`Unknown item "${name}".${didYouMean(resolved)} Run: ${RUN} add --list`);
321
382
  // Not in this package's registry — it may be newer than this CLI. Ask the site before giving up.
322
383
  const remoteMeta = { name: resolved, title: resolved, tier: 'pro', dependencies: [], unknownLocally: true };
323
- return stageRemoteItem(remoteMeta, resolved);
384
+ try {
385
+ return await stageRemoteItem(remoteMeta, resolved);
386
+ } catch (e) {
387
+ if (e instanceof CliError) e.message += didYouMean(resolved);
388
+ throw e;
389
+ }
324
390
  }
325
391
  const filesRoot = bundledFilesRoot(meta);
326
392
  if (filesRoot) return { meta, filesRoot, resolved };
@@ -666,101 +732,229 @@ function collectFiles(src, dest, files = []) {
666
732
  return files;
667
733
  }
668
734
 
669
- function identicalToRegistry(destFile, filesRoot, projectRoot, config) {
735
+ /** Every file an item writes: its registry source, where it lands, and its path under src/ in the registry. */
736
+ function registryFiles(filesRoot, projectRoot, config) {
670
737
  const registrySrc = path.join(filesRoot, 'src');
671
- for (const folder of fs.readdirSync(registrySrc)) {
672
- const destRoot = path.join(projectRoot, config.paths[folder] ?? (folder === 'app' ? 'src/app' : `src/${folder}`));
673
- if (destFile.startsWith(destRoot + path.sep)) {
674
- const src = path.join(registrySrc, folder, path.relative(destRoot, destFile));
675
- return fs.existsSync(src) && fs.readFileSync(src, 'utf8') === fs.readFileSync(destFile, 'utf8');
738
+ if (!fs.existsSync(registrySrc)) fail('Registry layout must include src/ folder.');
739
+ const files = [];
740
+ for (const entry of fs.readdirSync(registrySrc, { withFileTypes: true })) {
741
+ if (!entry.isDirectory()) continue;
742
+ const from = path.join(registrySrc, entry.name);
743
+ for (const src of collectFiles(from, from)) {
744
+ const registryRel = path.relative(registrySrc, src).split(path.sep).join('/');
745
+ files.push({ src, registryRel, dest: registryPathToProjectPath(registryRel, config, projectRoot) });
676
746
  }
677
747
  }
678
- return false;
748
+ return files;
679
749
  }
680
750
 
681
- function planRegistryFiles(filesRoot, projectRoot, config) {
682
- const registrySrc = path.join(filesRoot, 'src');
683
- if (!fs.existsSync(registrySrc)) {
684
- fail('Registry layout must include src/ folder.');
751
+ /**
752
+ * The existing path that differs from `dest` (or one of its folders) only by case — `components/ui/Skeleton.tsx`
753
+ * for `components/ui/skeleton.tsx` — or null. Read from the folder listings, so it's the same answer on every OS.
754
+ */
755
+ function caseClashOf(projectRoot, dest) {
756
+ let dir = projectRoot;
757
+ for (const seg of path.relative(projectRoot, dest).split(path.sep)) {
758
+ let names;
759
+ try {
760
+ names = fs.readdirSync(dir);
761
+ } catch {
762
+ return null; // folder doesn't exist yet: nothing below it can clash
763
+ }
764
+ const twin = caseClash(names, seg);
765
+ if (twin) return path.join(dir, twin);
766
+ dir = path.join(dir, seg);
685
767
  }
768
+ return null;
769
+ }
686
770
 
687
- const folderDest = {
688
- app: config.paths.app ?? 'src/app',
689
- components: config.paths.components ?? 'src/components',
690
- lib: config.paths.lib ?? 'src/lib',
691
- constants: config.paths.constants ?? 'src/constants',
692
- hooks: config.paths.hooks ?? 'src/hooks',
771
+ const posixRel = (projectRoot, file) => path.relative(projectRoot, file).split(path.sep).join('/');
772
+
773
+ /** Whether diffs get colour: a terminal, and NO_COLOR unset. */
774
+ const COLOR = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
775
+
776
+ /** At most this many lines of one file's diff are printed; `diff <item>` is the place for the rest. */
777
+ const DIFF_LINE_LIMIT = 120;
778
+
779
+ /** A unified diff as printable lines (coloured in a terminal), cut at DIFF_LINE_LIMIT. */
780
+ function diffLines(diff, more = '') {
781
+ const lines = diff.replace(/\n$/, '').split('\n');
782
+ const paint = (line) => {
783
+ if (!COLOR || /^(---|\+\+\+) /.test(line)) return line;
784
+ if (line.startsWith('+')) return `\x1b[32m${line}\x1b[0m`;
785
+ if (line.startsWith('-')) return `\x1b[31m${line}\x1b[0m`;
786
+ if (line.startsWith('@@')) return `\x1b[36m${line}\x1b[0m`;
787
+ return line;
693
788
  };
789
+ const shown = lines.slice(0, DIFF_LINE_LIMIT).map(paint);
790
+ if (lines.length > DIFF_LINE_LIMIT) shown.push(`… ${lines.length - DIFF_LINE_LIMIT} more line(s)${more}`);
791
+ return shown;
792
+ }
694
793
 
695
- const planned = [];
696
- for (const entry of fs.readdirSync(registrySrc, { withFileTypes: true })) {
697
- if (!entry.isDirectory()) continue;
698
- const destRel = folderDest[entry.name] ?? `src/${entry.name}`;
699
- planned.push(
700
- ...collectFiles(
701
- path.join(registrySrc, entry.name),
702
- path.join(projectRoot, destRel)
703
- )
794
+ /**
795
+ * What `add` / `update` would do with each file of an item, decided before anything is written:
796
+ * write (new) · same (already identical) · refresh (ours and untouched since: components.json's hash matches) ·
797
+ * keep (shared kit file you edited) · overwrite (--force)
798
+ * plus `problems` that stop the whole item: a different-case twin (never replaced, --force included), a file of
799
+ * yours where the item needs one of ours, a file you changed since it was installed, or a kept kit.tsx that is too old.
800
+ * `flags.update`: liquidglass-tab only refreshes its framework files (tabs.ts and screens are yours), and changed
801
+ * files are reported with their diff.
802
+ */
803
+ function planItem(item, projectRoot, config, flags) {
804
+ const { meta, filesRoot, resolved } = item;
805
+ const shared = new Set(meta.sharedFiles ?? []);
806
+ const files = [];
807
+ const problems = [];
808
+ const theirs = [];
809
+ const conflicts = [];
810
+ const componentsDir = posixRel(projectRoot, path.join(projectRoot, config.paths.components ?? 'src/components'));
811
+ const kitDir = `${componentsDir}/kit`;
812
+ const frameworkOnly = flags.update && !flags.force && resolved === 'liquidglass-tab';
813
+
814
+ for (const { src, dest, registryRel } of registryFiles(filesRoot, projectRoot, config)) {
815
+ if (frameworkOnly && !LIQUIDGLASS_FRAMEWORK_PATHS.includes(registryRel)) continue;
816
+ const rel = posixRel(projectRoot, dest);
817
+ const twin = caseClashOf(projectRoot, dest);
818
+ if (twin) {
819
+ const inComponents = registryRel.startsWith('components/');
820
+ problems.push(
821
+ `${rel} clashes with your ${posixRel(projectRoot, twin)} (macOS and Windows treat them as one file, so it would replace yours).`,
822
+ inComponents
823
+ ? ` • Install it next to yours: ${RUN} add ${resolved} --dir ${kitDir}`
824
+ : ` • Rename or move your ${posixRel(projectRoot, twin)}, then add ${resolved} again.`,
825
+ ` • Or keep yours as it is: nothing was written for ${resolved}.`
826
+ );
827
+ continue;
828
+ }
829
+ const text = registryText(src, config);
830
+ let stat = null;
831
+ try {
832
+ stat = fs.lstatSync(dest);
833
+ } catch {
834
+ // doesn't exist yet
835
+ }
836
+ if (!stat) {
837
+ files.push({ rel, dest, text, registryRel, action: 'write' });
838
+ continue;
839
+ }
840
+ // Never write through a link (it could point anywhere, e.g. from a cloned repo) or over a folder.
841
+ if (stat.isSymbolicLink() || !stat.isFile()) {
842
+ problems.push(`${rel} is a ${stat.isSymbolicLink() ? 'symlink' : 'folder'}, so it isn't replaced (not even with --force). Move it, then add ${resolved} again.`);
843
+ continue;
844
+ }
845
+ const have = fs.readFileSync(dest, 'utf8');
846
+ const status = fileStatus(recordedHash(config.files, rel), have, text);
847
+ if (status === 'current') {
848
+ files.push({ rel, dest, text, registryRel, action: 'same' });
849
+ } else if (status === 'pristine') {
850
+ // Written by the CLI and untouched since (its hash is the one components.json recorded): safe to refresh.
851
+ files.push({ rel, dest, text, registryRel, action: 'refresh' });
852
+ } else if (shared.has(registryRel) && looksLikeKitFile(have)) {
853
+ // A kit file you edited: kept, unless it's too old for this item (missing kit API would crash at runtime).
854
+ const need = kitVersion(text);
855
+ const got = kitVersion(have);
856
+ if (!flags.force && need !== null && got !== null && got < need) {
857
+ problems.push(
858
+ `${meta.title} needs a newer kit than your edited ${rel} (kit v${got}, needs v${need}).`,
859
+ ` • Re-run with --force to replace it (your edits to it are lost — back it up first), or merge the new version by hand.`
860
+ );
861
+ }
862
+ files.push({ rel, dest, text, registryRel, action: flags.force ? 'overwrite' : 'keep' });
863
+ } else if (flags.force) {
864
+ files.push({ rel, dest, text, registryRel, action: 'overwrite' });
865
+ } else if (shared.has(registryRel)) {
866
+ theirs.push(rel);
867
+ } else {
868
+ conflicts.push({ rel, have, text, status });
869
+ }
870
+ }
871
+
872
+ if (theirs.length) {
873
+ problems.push(
874
+ `${theirs.join(', ')} already exist${theirs.length === 1 ? 's' : ''} and isn't an ExpoKit file, but ${meta.title} imports ExpoKit's version from there.`,
875
+ ...(theirs.some((r) => r.startsWith(`${componentsDir}/`)) ? [` • Install the kit's copy elsewhere: ${RUN} add ${resolved} --dir ${kitDir}`] : []),
876
+ ` • Or replace yours (it is lost): ${RUN} add ${resolved} --force`
877
+ );
878
+ }
879
+ if (conflicts.length && flags.update) {
880
+ problems.push(
881
+ `${conflicts.length} file(s) changed since ${meta.title} was installed, so none of it was updated:`,
882
+ ...conflicts.flatMap((c) => [
883
+ ` • ${c.rel}${c.status === 'untracked' ? ' (no install record: added by an older CLI)' : ''}`,
884
+ ...diffLines(unifiedDiff(c.have, c.text, { from: `${c.rel} (yours)`, to: `${c.rel} (upstream)` }), ` — see: ${RUN} diff ${resolved}`).map(
885
+ (line) => ` ${line}`
886
+ ),
887
+ ]),
888
+ ` • Keep yours: nothing was changed. Copy the upstream lines (+) you want into your file; update lists it until it matches.`,
889
+ ` • Take upstream (your changes are lost): ${RUN} update ${resolved} --force`
890
+ );
891
+ } else if (conflicts.length) {
892
+ problems.push(
893
+ `Would overwrite ${conflicts.length} file(s) that differ from ${meta.title}:`,
894
+ ...conflicts.map((c) => ` • ${c.rel}`),
895
+ ` • See what differs: ${RUN} diff ${resolved}`,
896
+ ` • Replace them (your changes are lost): ${RUN} add ${resolved} --force`
704
897
  );
705
898
  }
706
- return planned;
899
+ return { files, problems };
707
900
  }
708
901
 
709
- function copyDir(src, dest, projectRoot, options) {
710
- const { dryRun } = options;
711
- if (!fs.existsSync(dest) && !dryRun) fs.mkdirSync(dest, { recursive: true });
712
-
713
- for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
714
- const srcPath = path.join(src, entry.name);
715
- const destPath = path.join(dest, entry.name);
716
- if (entry.isDirectory()) {
717
- copyDir(srcPath, destPath, projectRoot, options);
718
- } else {
719
- const rel = path.relative(projectRoot, destPath);
720
- const exists = fs.existsSync(destPath);
721
- const same = exists && fs.readFileSync(destPath, 'utf8') === fs.readFileSync(srcPath, 'utf8');
722
- const keep = exists && !same && !options.force && options.shared?.has(destPath);
723
- if (dryRun) {
724
- log(` would ${same ? 'keep (unchanged)' : keep ? 'keep (shared, yours)' : 'write'} ${rel}${exists && !same && !keep ? ' (overwrite)' : ''}`);
725
- continue;
726
- }
727
- if (same) continue;
728
- if (keep) {
729
- log(` = ${rel} (shared file kept — use --force to replace)`);
730
- continue;
731
- }
732
- if (exists) warn(`Overwriting ${rel}`);
733
- fs.mkdirSync(path.dirname(destPath), { recursive: true });
734
- fs.writeFileSync(destPath, fs.readFileSync(srcPath, 'utf8'));
735
- log(` + ${rel}`);
902
+ /** Writes a planned item. Returns the project files it wrote (for Prettier). */
903
+ function applyPlan(plan) {
904
+ const written = [];
905
+ for (const file of plan.files) {
906
+ if (file.action === 'same') continue;
907
+ if (file.action === 'keep') {
908
+ log(` = ${file.rel} (shared file kept — use --force to replace)`);
909
+ continue;
736
910
  }
911
+ if (file.action === 'overwrite') warn(`Overwriting ${file.rel}`);
912
+ fs.mkdirSync(path.dirname(file.dest), { recursive: true });
913
+ fs.writeFileSync(file.dest, file.text);
914
+ written.push(file.dest);
915
+ log(` ${file.action === 'refresh' ? '~' : '+'} ${file.rel}${file.action === 'refresh' ? ' (updated)' : ''}`);
737
916
  }
917
+ return written;
738
918
  }
739
919
 
740
- function mapRegistryToProject(filesRoot, projectRoot, config, options) {
741
- const registrySrc = path.join(filesRoot, 'src');
742
- if (!fs.existsSync(registrySrc)) {
743
- fail('Registry layout must include src/ folder.');
920
+ function printPlan(plan) {
921
+ for (const file of plan.files) {
922
+ const what = {
923
+ write: 'would write',
924
+ same: 'unchanged',
925
+ refresh: 'would update',
926
+ keep: 'would keep (shared, yours)',
927
+ overwrite: 'would overwrite',
928
+ }[file.action];
929
+ log(` ${what} ${file.rel}`);
744
930
  }
931
+ }
745
932
 
746
- const folderDest = {
747
- app: config.paths.app ?? 'src/app',
748
- components: config.paths.components ?? 'src/components',
749
- lib: config.paths.lib ?? 'src/lib',
750
- constants: config.paths.constants ?? 'src/constants',
751
- hooks: config.paths.hooks ?? 'src/hooks',
752
- };
753
-
754
- for (const entry of fs.readdirSync(registrySrc, { withFileTypes: true })) {
755
- if (!entry.isDirectory()) continue;
756
- const destRel = folderDest[entry.name] ?? `src/${entry.name}`;
757
- copyDir(
758
- path.join(registrySrc, entry.name),
759
- path.join(projectRoot, destRel),
760
- projectRoot,
761
- options
762
- );
933
+ /**
934
+ * After a write: components.json remembers each file's hash as it now is on disk (after Prettier), so the next
935
+ * `update` / `add` can tell "ours, untouched" from "you changed it". Kept files (your edits) keep their old record.
936
+ */
937
+ function recordInstall(config, name, plan, dir) {
938
+ for (const file of plan.files) {
939
+ if (file.action === 'keep') continue;
940
+ try {
941
+ config.files[file.rel] = contentHash(fs.readFileSync(file.dest, 'utf8'));
942
+ } catch {
943
+ delete config.files[file.rel];
944
+ }
763
945
  }
946
+ const entry = { version: getPackageVersion() };
947
+ const kit = plan.files.find((f) => f.registryRel === 'lib/kit.tsx');
948
+ if (kit) {
949
+ try {
950
+ const v = kitVersion(fs.readFileSync(kit.dest, 'utf8'));
951
+ if (v !== null) entry.kitVersion = v;
952
+ } catch {
953
+ // unreadable: no version to record
954
+ }
955
+ }
956
+ if (dir) entry.dir = dir;
957
+ config.installed[name] = entry;
764
958
  }
765
959
 
766
960
  function ensureExpoRouterMain(projectRoot, dryRun) {
@@ -842,21 +1036,57 @@ function runExpoInstall(projectRoot, packages, dryRun) {
842
1036
  log(` would run: npx expo install ${packages.join(' ')}`);
843
1037
  return true;
844
1038
  }
845
- log(`Installing: ${packages.join(', ')}`);
1039
+ log(`Installing ${packages.join(', ')}…`);
1040
+ // npm's own output (engine warnings, audit notes) stays hidden unless the install fails.
846
1041
  const result = spawnSync('npx', ['expo', 'install', ...packages], {
847
1042
  cwd: projectRoot,
848
1043
  env: childEnv,
849
- stdio: 'inherit',
1044
+ stdio: ['ignore', 'pipe', 'pipe'],
1045
+ encoding: 'utf8',
1046
+ maxBuffer: 64 * 1024 * 1024,
850
1047
  shell: process.platform === 'win32',
851
1048
  });
852
1049
  if (result.status !== 0) {
853
- warn('expo install failed — run manually:');
1050
+ const lines = `${result.stdout ?? ''}\n${result.stderr ?? ''}`
1051
+ .split(/\r?\n/)
1052
+ .filter((line) => line.trim() && !/\bEBADENGINE\b|^npm warn (engine| )/i.test(line));
1053
+ if (lines.length) console.log(lines.slice(-25).join('\n'));
1054
+ warn(`expo install failed${result.error ? ` (${result.error.message})` : ''} — run it yourself:`);
854
1055
  console.log(` npx expo install ${packages.join(' ')}`);
855
1056
  return false;
856
1057
  }
857
1058
  return true;
858
1059
  }
859
1060
 
1061
+ /** The project's own Prettier (node_modules/.bin), when package.json lists it; never a download. */
1062
+ function projectPrettier(projectRoot, pkg) {
1063
+ if (!pkg?.dependencies?.prettier && !pkg?.devDependencies?.prettier) return null;
1064
+ const bin = path.join(projectRoot, 'node_modules', '.bin', process.platform === 'win32' ? 'prettier.cmd' : 'prettier');
1065
+ return fs.existsSync(bin) ? bin : null;
1066
+ }
1067
+
1068
+ /** Formats the files `add` wrote with the project's Prettier config, so `npm run lint` / `format --check` pass. */
1069
+ function formatWritten(projectRoot, pkg, files, flags) {
1070
+ // Windows runs prettier.cmd through a shell: only plain paths go on its command line.
1071
+ const rels = files.map((f) => path.relative(projectRoot, f)).filter((rel) => /^[A-Za-z0-9_.()[\]\/\\-]+$/.test(rel));
1072
+ if (!flags.format || !rels.length) return;
1073
+ const bin = projectPrettier(projectRoot, pkg);
1074
+ if (!bin) return;
1075
+ if (flags.dryRun) {
1076
+ log(' would format the new files with your Prettier config (--no-format to skip)');
1077
+ return;
1078
+ }
1079
+ const result = spawnSync(bin, ['--write', '--log-level', 'warn', ...rels], {
1080
+ cwd: projectRoot,
1081
+ env: childEnv,
1082
+ stdio: ['ignore', 'pipe', 'pipe'],
1083
+ encoding: 'utf8',
1084
+ shell: process.platform === 'win32',
1085
+ });
1086
+ if (result.status === 0) log(`Formatted ${rels.length} file(s) with your Prettier config.`);
1087
+ else warn(`Prettier couldn't format the new files: ${(result.stderr || result.stdout || result.error?.message || '').trim().split('\n')[0]}`);
1088
+ }
1089
+
860
1090
  /** After an add: can the item run in Expo Go, or does it need a development build? */
861
1091
  function printRunHint(deps) {
862
1092
  const native = needsDevBuild(deps);
@@ -894,12 +1124,105 @@ function createExpoApp(cwd, dir, flags) {
894
1124
  return target;
895
1125
  }
896
1126
 
897
- function cmdInit(cwd, flags, dir) {
1127
+ /** Asks one theme question until the answer passes `check` (Enter keeps the default). */
1128
+ async function ask(rl, question, check) {
1129
+ for (;;) {
1130
+ const answer = (await new Promise((resolve) => rl.question(question, resolve))).trim();
1131
+ try {
1132
+ check(answer);
1133
+ return answer;
1134
+ } catch (e) {
1135
+ if (!(e instanceof CliError)) throw e;
1136
+ warn(e.message.replace(/^--\w+ /, 'It '));
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ /** `init` in a terminal: brand colour, font, radius and light / dark, each skippable with Enter. */
1142
+ async function askTheme() {
1143
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1144
+ rl.on('SIGINT', () => {
1145
+ rl.close();
1146
+ process.stdout.write('\n');
1147
+ process.exit(130);
1148
+ });
1149
+ try {
1150
+ log('Theme (Enter keeps the default; change it later in the file init writes):');
1151
+ const brand = await ask(rl, ' Brand colour, hex (Enter: monochrome) › ', (v) => themeOptions({ brand: v }));
1152
+ const font = await ask(rl, ' Font family (Enter: system font) › ', (v) => themeOptions({ font: v }));
1153
+ const radius = await ask(rl, ' Corner radius in points (Enter: 12) › ', (v) => themeOptions({ radius: v }));
1154
+ const scheme = await ask(rl, ' light, dark or system (Enter: system) › ', (v) => themeOptions({ scheme: v }));
1155
+ return themeOptions({ brand, font, radius, scheme });
1156
+ } finally {
1157
+ rl.close();
1158
+ }
1159
+ }
1160
+
1161
+ const THEME_FLAGS = ['brand', 'font', 'radius', 'scheme'];
1162
+
1163
+ /**
1164
+ * The theme step of `init`: adds kit.tsx (tracked like any add) and writes <lib>/kit-theme.tsx, an `AppTheme`
1165
+ * wrapper around KitProvider with the chosen values. An existing kit-theme.tsx is never replaced: the snippet is
1166
+ * printed instead.
1167
+ */
1168
+ function writeTheme(projectRoot, config, theme, flags) {
1169
+ const pkg = readJsonFile(path.join(projectRoot, 'package.json'), 'package.json');
1170
+ const kitMeta = readMeta('kit');
1171
+ const kit = { meta: kitMeta, filesRoot: bundledFilesRoot(kitMeta), resolved: 'kit' };
1172
+ const plan = planItem(kit, projectRoot, config, { force: false });
1173
+ const written = [];
1174
+ if (plan.problems.length) {
1175
+ warn(`Couldn't add lib/kit.tsx, so no theme file was written:`);
1176
+ for (const line of plan.problems) console.log(` ${line}`);
1177
+ return;
1178
+ }
1179
+ const source = kitThemeSource(theme, config.aliases.lib);
1180
+ const themePath = path.join(projectRoot, folderDest(config, 'lib'), 'kit-theme.tsx');
1181
+ const themeRel = posixRel(projectRoot, themePath);
1182
+ const exists = (() => {
1183
+ try {
1184
+ fs.lstatSync(themePath);
1185
+ return true;
1186
+ } catch {
1187
+ return false;
1188
+ }
1189
+ })();
1190
+ if (flags.dryRun) {
1191
+ printPlan(plan);
1192
+ log(exists ? ` would keep your ${themeRel}` : ` would write ${themeRel}`);
1193
+ return;
1194
+ }
1195
+ written.push(...applyPlan(plan));
1196
+ recordInstall(config, 'kit', plan, null);
1197
+ saveConfig(projectRoot, config);
1198
+ if (exists) {
1199
+ warn(`${themeRel} already exists, so it was kept. The theme you picked, to merge by hand:`);
1200
+ console.log(`\n${source}`);
1201
+ } else {
1202
+ fs.mkdirSync(path.dirname(themePath), { recursive: true });
1203
+ fs.writeFileSync(themePath, source);
1204
+ written.push(themePath);
1205
+ log(` + ${themeRel}`);
1206
+ }
1207
+ formatWritten(projectRoot, pkg, written, flags);
1208
+ const lib = String(config.aliases.lib).replace(/\/+$/, '');
1209
+ log('Wrap your root layout (app/_layout.tsx) in it:');
1210
+ console.log(` import { AppTheme } from "${lib}/kit-theme";`);
1211
+ console.log(' <AppTheme><Stack /></AppTheme>');
1212
+ console.log(` Bring your own design system: ${SITE_URL}/docs/design-tokens`);
1213
+ }
1214
+
1215
+ /**
1216
+ * `init`: components.json, the `@/*` alias and package.json "main", then the theme (flags, or questions in a
1217
+ * terminal). `theme: false` for the quiet init `add` runs on a project without components.json.
1218
+ */
1219
+ async function cmdInit(cwd, flags, dir, { theme = true } = {}) {
898
1220
  if (dir) {
899
1221
  // Already inside an Expo app: `init <dir>` still means "create a new one there".
1222
+ themeOptions(flags); // a bad --brand fails before create-expo-app runs
900
1223
  const target = createExpoApp(cwd, dir, flags);
901
1224
  if (!target) return;
902
- cmdInit(target, flags);
1225
+ await cmdInit(target, flags);
903
1226
  log('');
904
1227
  log(`Next: cd ${dir} && npx expo start — then scan the QR with Expo Go.`);
905
1228
  return;
@@ -907,9 +1230,17 @@ function cmdInit(cwd, flags, dir) {
907
1230
  const projectRoot = findProjectRoot(cwd);
908
1231
  if (!projectRoot) fail(`Not inside an Expo project (expo dependency not found). Start a new app with: ${RUN} init my-app`);
909
1232
 
1233
+ // Checked before anything is written: these values end up in a .tsx file.
1234
+ const themeGiven = THEME_FLAGS.some((k) => flags[k] !== undefined);
1235
+ let picked = themeOptions(flags);
1236
+
910
1237
  const configPath = path.join(projectRoot, 'components.json');
911
1238
  if (fs.existsSync(configPath)) {
912
- warn('components.json already exists.');
1239
+ if (!theme || !themeGiven) {
1240
+ warn(`components.json already exists.${theme ? ` To (re)write the theme: ${RUN} init --brand "#6366f1"` : ''}`);
1241
+ return;
1242
+ }
1243
+ writeTheme(projectRoot, loadConfig(projectRoot), picked, flags);
913
1244
  return;
914
1245
  }
915
1246
 
@@ -923,10 +1254,15 @@ function cmdInit(cwd, flags, dir) {
923
1254
  // Plan the tsconfig edit first: a broken tsconfig must not leave a half-initialised project.
924
1255
  const tsPlan = planTsconfigAlias(projectRoot, aliasTarget);
925
1256
 
1257
+ if (theme && !themeGiven && !flags.yes && !flags.dryRun && process.stdin.isTTY && process.stdout.isTTY) {
1258
+ picked = await askTheme();
1259
+ }
1260
+
926
1261
  if (flags.dryRun) {
927
1262
  log(`Would create components.json (${layout} layout)`);
928
1263
  if (tsPlan?.changed) log(`Would add @/* → ${aliasTarget[0]} in tsconfig.json`);
929
1264
  ensureExpoRouterMain(projectRoot, true);
1265
+ if (theme) writeTheme(projectRoot, loadConfig(projectRoot), picked, flags);
930
1266
  return;
931
1267
  }
932
1268
 
@@ -940,6 +1276,7 @@ function cmdInit(cwd, flags, dir) {
940
1276
  log(`Created components.json (${layout} layout)`);
941
1277
 
942
1278
  ensureExpoRouterMain(projectRoot, false);
1279
+ if (theme) writeTheme(projectRoot, loadConfig(projectRoot), picked, flags);
943
1280
  log(`Done. Add items with: ${RUN} add <name>`);
944
1281
  }
945
1282
 
@@ -947,209 +1284,311 @@ function cmdInit(cwd, flags, dir) {
947
1284
  * After a successful add: tells the site which item was installed — just its name, no token or project
948
1285
  * data (README "Privacy"). Off with DO_NOT_TRACK=1 / EXPO_KIT_TELEMETRY=0 and in CI. Never fails the add.
949
1286
  */
950
- async function reportInstalled(name) {
951
- if (!telemetryEnabled(process.env)) return;
1287
+ async function reportInstalled(names) {
1288
+ if (!telemetryEnabled(process.env) || !names.length) return;
952
1289
  try {
953
1290
  parseSiteUrl(SITE_URL);
954
- const res = await fetch(`${SITE_URL}/api/cli/installed`, {
955
- method: 'POST',
956
- headers: { 'content-type': 'application/json' },
957
- body: JSON.stringify({ items: [name] }),
958
- signal: AbortSignal.timeout(1500),
959
- });
960
- await res.arrayBuffer();
1291
+ // The site takes small bodies (2 KB): a long batch goes in a few posts.
1292
+ for (let i = 0; i < names.length; i += 20) {
1293
+ const res = await fetch(`${SITE_URL}/api/cli/installed`, {
1294
+ method: 'POST',
1295
+ headers: { 'content-type': 'application/json' },
1296
+ body: JSON.stringify({ items: names.slice(i, i + 20) }),
1297
+ signal: AbortSignal.timeout(1500),
1298
+ });
1299
+ await res.arrayBuffer();
1300
+ }
961
1301
  } catch {
962
- // offline, slow or blocked: the count just misses this one
1302
+ // offline, slow or blocked: the count just misses these
963
1303
  }
964
1304
  }
965
1305
 
966
- /** `report: false` for re-adds that aren't new installs (`update --force`). */
967
- async function cmdAdd(args, cwd, { report = true } = {}) {
968
- const { flags, positional } = parseFlags(args);
969
- // `add a b c` → add each in turn with the same flags.
970
- if (positional.length > 1 && !positional.includes('--list')) {
971
- const flagArgs = args.filter((a) => a.startsWith('--'));
972
- for (const item of positional) await cmdAdd([item, ...flagArgs], cwd, { report });
973
- return;
1306
+ /**
1307
+ * `add --dir <folder>`: this run's config with the registry's components/ going to <folder>, and their
1308
+ * `@/components/…` imports pointing there. The folder must be reachable through the project's `@/*` alias.
1309
+ */
1310
+ function withComponentsDir(projectRoot, config, dir) {
1311
+ let aliasRoot = null;
1312
+ try {
1313
+ aliasRoot = aliasRootFromTsconfig(parseJsonc(fs.readFileSync(path.join(projectRoot, 'tsconfig.json'), 'utf8')));
1314
+ } catch {
1315
+ // no / unreadable tsconfig: fall back to the layout init would set up
974
1316
  }
975
- const name = positional[0];
976
-
977
- if (!name || name === '--list') {
978
- listRegistry();
979
- return;
1317
+ if (aliasRoot === null) aliasRoot = detectProjectLayout(projectRoot).layout === 'src' ? 'src' : '';
1318
+ const alias = aliasTargetForDir(dir, aliasRoot);
1319
+ const example = `${aliasRoot ? `${aliasRoot}/` : ''}components/kit`;
1320
+ if (!alias || !isInsideDir(projectRoot, dir)) {
1321
+ fail(`--dir ${dir}: use a folder inside ${aliasRoot ? `${aliasRoot}/` : 'the project'} so "@/…" imports reach it, e.g. --dir ${example}`);
980
1322
  }
1323
+ const folder = dir.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
1324
+ return { ...config, paths: { ...config.paths, components: folder }, aliases: { ...config.aliases, components: alias } };
1325
+ }
981
1326
 
982
- const projectRoot = findProjectRoot(cwd);
983
- if (!projectRoot) fail('Not inside an Expo project. Run from your app root.');
1327
+ /** `import { X } from "…"` for an item's main component, or null when it has none (templates, lib-only items). */
1328
+ function importLine(item, config) {
1329
+ const { filesRoot, resolved, meta } = item;
1330
+ const folder = ['ui', 'blocks', 'charts'].find((f) => fs.existsSync(path.join(filesRoot, 'src', 'components', f, `${resolved}.tsx`)));
1331
+ if (!folder) return null;
1332
+ const exported = pickMainExport(fs.readFileSync(path.join(filesRoot, 'src', 'components', folder, `${resolved}.tsx`), 'utf8'), [
1333
+ meta.title,
1334
+ resolved,
1335
+ ]);
1336
+ return exported ? `import { ${exported} } from "${(config.aliases.components ?? '@/components').replace(/\/+$/, '')}/${folder}/${resolved}";` : null;
1337
+ }
984
1338
 
985
- const configPath = path.join(projectRoot, 'components.json');
986
- if (!fs.existsSync(configPath)) {
987
- warn('No components.json — running init first.');
988
- cmdInit(cwd, flags);
989
- }
1339
+ /**
1340
+ * The config an item is written with: `--dir` (this run), else the folder it was installed into (components.json,
1341
+ * untrusted, so it goes through the same checks as --dir), else components.json's paths.
1342
+ */
1343
+ function targetFor(projectRoot, config, name, flags) {
1344
+ const dir = flags.dir ?? (flags.update ? config.installed[name]?.dir : undefined);
1345
+ if (!dir) return { target: config, dir: null };
1346
+ const target = withComponentsDir(projectRoot, config, dir);
1347
+ return { target, dir: target.paths.components };
1348
+ }
990
1349
 
991
- const config = loadConfig(projectRoot);
992
- const { meta, filesRoot, resolved } = await loadRegistryItem(name);
1350
+ /** Items components.json says were added (plus a liquidglass-tab set up before it kept records). */
1351
+ function installedNames(projectRoot, config) {
1352
+ return [...new Set([...Object.keys(config.installed), ...detectInstalledTemplates(projectRoot, config)])];
1353
+ }
993
1354
 
994
- const shared = new Set(
995
- (meta.sharedFiles ?? []).map((rel) => registryPathToProjectPath(rel, config, projectRoot))
996
- );
997
- const planned = planRegistryFiles(filesRoot, projectRoot, config);
998
- const srcOf = (file) => {
999
- const rel = (meta.sharedFiles ?? []).find((r) => registryPathToProjectPath(r, config, projectRoot) === file);
1000
- return rel ? path.join(filesRoot, 'src', rel) : null;
1001
- };
1002
- const conflicts = planned.filter((file) => {
1003
- if (!fs.existsSync(file) || shared.has(file)) return false;
1004
- return !identicalToRegistry(file, filesRoot, projectRoot, config) && !srcOf(file);
1005
- });
1355
+ /**
1356
+ * Whether `name` is in this project: recorded in components.json, or (for projects from before the records) a
1357
+ * bundled item whose own files are there. Pro items need the record, so nothing is downloaded to find out.
1358
+ */
1359
+ function isInstalled(projectRoot, config, name) {
1360
+ if (installedNames(projectRoot, config).includes(name)) return true;
1361
+ const meta = isValidItemName(name) ? readMeta(name) : null;
1362
+ const root = meta && bundledFilesRoot(meta);
1363
+ if (!root) return false;
1364
+ const shared = new Set(meta.sharedFiles ?? []);
1365
+ return registryFiles(root, projectRoot, config).some((f) => !shared.has(f.registryRel) && fs.existsSync(f.dest));
1366
+ }
1006
1367
 
1007
- // A kept (edited) shared kit.tsx older than this item needs would crash at runtime (missing kit API).
1008
- const outdated = [...shared].flatMap((file) => {
1009
- const src = srcOf(file);
1010
- if (!src || !fs.existsSync(file)) return [];
1011
- const need = kitVersion(fs.readFileSync(src, 'utf8'));
1012
- const have = kitVersion(fs.readFileSync(file, 'utf8'));
1013
- return need !== null && have !== null && have < need ? [{ file, have, need }] : [];
1014
- });
1015
- if (outdated.length && !flags.force) {
1016
- console.log('');
1017
- fail(
1018
- `${meta.title} needs a newer kit than your edited copy:\n` +
1019
- outdated.map((o) => ` • ${path.relative(projectRoot, o.file)} (kit v${o.have}, needs v${o.need})`).join('\n') +
1020
- `\nRe-run with --force to replace it (your edits to it are lost — back it up first), or merge the new version by hand.`
1021
- );
1022
- }
1368
+ /**
1369
+ * `add a b c` / `update a b c`: every item is planned and written on its own (one that can't be added doesn't stop
1370
+ * the rest), then one `npx expo install` for the packages the items need and package.json doesn't list yet, then
1371
+ * the install records and a summary. Exits 1 when any item wasn't written. `flags.update`: only installed items,
1372
+ * and files you changed stop the item with their diff (unless --force).
1373
+ */
1374
+ async function runBatch(names, projectRoot, config, flags, { report }) {
1375
+ const results = [];
1376
+ const written = [];
1023
1377
 
1024
- if (flags.dryRun) {
1025
- log(`Dry run — ${meta.title} (${resolved})\n`);
1026
- for (const file of planned) {
1027
- const rel = path.relative(projectRoot, file);
1028
- const exists = fs.existsSync(file);
1029
- const same = exists && identicalToRegistry(file, filesRoot, projectRoot, config);
1030
- const isShared = (meta.sharedFiles ?? []).some((r) => registryPathToProjectPath(r, config, projectRoot) === file);
1031
- log(
1032
- same
1033
- ? ` unchanged ${rel}`
1034
- : ` would write ${rel}${exists ? (isShared ? ' (shared — kept unless --force)' : ' (overwrite with --force)') : ''}`
1035
- );
1378
+ for (const name of names) {
1379
+ console.log('');
1380
+ let item;
1381
+ let plan;
1382
+ let dir;
1383
+ try {
1384
+ if (flags.update && !isInstalled(projectRoot, config, resolveRegistryName(name))) {
1385
+ fail(`${resolveRegistryName(name)} isn't installed here. Add it: ${RUN} add ${resolveRegistryName(name)}`);
1386
+ }
1387
+ item = await loadRegistryItem(name);
1388
+ const where = targetFor(projectRoot, config, item.resolved, flags);
1389
+ dir = where.dir;
1390
+ item.target = where.target;
1391
+ plan = planItem(item, projectRoot, item.target, flags);
1392
+ } catch (e) {
1393
+ if (!(e instanceof CliError)) throw e;
1394
+ results.push({ name: item?.resolved ?? name, ok: false, lines: [e.message] });
1395
+ continue;
1036
1396
  }
1037
- if (conflicts.length && !flags.force) {
1038
- console.log('');
1039
- fail(
1040
- `Would overwrite ${conflicts.length} file(s). Re-run with --force:\n` +
1041
- conflicts.map((f) => ` • ${path.relative(projectRoot, f)}`).join('\n')
1042
- );
1397
+ const { meta, resolved } = item;
1398
+ const verb = flags.update ? 'Updating' : 'Adding';
1399
+ log(`${flags.dryRun ? 'Dry run — ' : `${verb} `}${meta.title} (${resolved})…`);
1400
+ if (flags.dryRun) printPlan(plan);
1401
+ if (plan.problems.length) {
1402
+ results.push({ name: resolved, ok: false, lines: plan.problems });
1403
+ continue;
1043
1404
  }
1405
+ if (!flags.dryRun) written.push(...applyPlan(plan));
1406
+ if (flags.update && !flags.dryRun && plan.files.every((f) => f.action === 'same' || f.action === 'keep')) log(' already up to date');
1044
1407
  if (resolved === 'liquidglass-tab') {
1045
- syncTabRoutes(projectRoot, config, flags);
1046
- ensureExpoRouterMain(projectRoot, true);
1408
+ syncTabRoutes(projectRoot, item.target, flags);
1409
+ ensureExpoRouterMain(projectRoot, flags.dryRun);
1047
1410
  }
1048
- runExpoInstall(projectRoot, meta.dependencies ?? [], true);
1049
- if (resolved !== 'liquidglass-tab') printRunHint(meta.dependencies);
1050
- return;
1411
+ results.push({ name: resolved, ok: true, item, plan, dir, importLine: importLine(item, item.target) });
1051
1412
  }
1052
1413
 
1053
- const copyOptions = { dryRun: false, force: flags.force, shared };
1054
-
1055
- if (conflicts.length && !flags.force) {
1056
- console.log('');
1057
- fail(
1058
- `Would overwrite ${conflicts.length} file(s). Re-run with --force:\n` +
1059
- conflicts.map((f) => ` • ${path.relative(projectRoot, f)}`).join('\n')
1060
- );
1061
- }
1414
+ const added = results.filter((r) => r.ok);
1415
+ const failed = results.filter((r) => !r.ok);
1416
+ const pkg = readJsonFile(path.join(projectRoot, 'package.json'), 'package.json');
1417
+ const deps = mergeDependencies(...added.map((r) => r.item.meta.dependencies ?? []));
1418
+ const missing = missingPackages(deps, pkg);
1419
+ const present = deps.filter((d) => !missing.includes(d));
1062
1420
 
1063
- log(`Adding ${meta.title} (${resolved})…\n`);
1064
- mapRegistryToProject(filesRoot, projectRoot, config, copyOptions);
1421
+ console.log('');
1422
+ formatWritten(projectRoot, pkg, written, flags);
1423
+ runExpoInstall(projectRoot, missing, flags.dryRun);
1424
+ if (present.length && !flags.update) log(`${flags.dryRun ? ' ' : ''}already in package.json: ${present.join(', ')}`);
1065
1425
 
1066
- if (resolved === 'liquidglass-tab') {
1067
- syncTabRoutes(projectRoot, config, copyOptions);
1068
- ensureExpoRouterMain(projectRoot, false);
1426
+ if (!flags.dryRun && added.length) {
1427
+ // Hashes are taken after Prettier, from the files as they are now. A one-off --dir is recorded per item,
1428
+ // never as the components.json default.
1429
+ for (const r of added) recordInstall(config, r.name, r.plan, r.dir);
1430
+ saveConfig(projectRoot, config);
1431
+ if (report) await reportInstalled(added.map((r) => r.name));
1069
1432
  }
1070
1433
 
1071
- const depsOk = runExpoInstall(projectRoot, meta.dependencies ?? [], false);
1072
-
1073
- if (depsOk) markTemplateInstalled(projectRoot, config, resolved);
1074
- if (report) await reportInstalled(resolved);
1075
-
1076
1434
  console.log('');
1077
- if (resolved === 'liquidglass-tab') {
1078
- log('Next steps:');
1079
- console.log(' • Edit src/lib/tabs.ts — labels, icon presets, colors, screen text');
1080
- console.log(` • Run: ${RUN} doctor`);
1081
- console.log(' • Build a dev client: npx expo run:ios (iOS 26 shows real Liquid Glass,');
1082
- console.log(' iOS 18 and older, Android and web get the glass fallback bar)');
1083
- console.log(' • After package updates: npx @expokit/cli@latest update');
1084
- } else {
1085
- const folder = ['ui', 'blocks', 'charts'].find((f) =>
1086
- fs.existsSync(path.join(filesRoot, 'src', 'components', f, `${resolved}.tsx`))
1087
- );
1088
- const main = folder ? path.join(filesRoot, 'src', 'components', folder, `${resolved}.tsx`) : null;
1089
- const exported = main ? pickMainExport(fs.readFileSync(main, 'utf8'), [meta.title, resolved]) : null;
1090
- if (exported) log(`Import: import { ${exported} } from "@/components/${folder}/${resolved}";`);
1091
- printRunHint(meta.dependencies);
1435
+ for (const r of results) {
1436
+ if (r.ok) {
1437
+ log(`✓ ${r.name}${r.importLine && !flags.update ? ` ${r.importLine}` : ''}`);
1438
+ } else {
1439
+ log(`✗ ${r.name} — ${r.lines[0]}`);
1440
+ for (const line of r.lines.slice(1)) console.log(` ${line}`);
1441
+ }
1442
+ }
1443
+ if (added.length && !flags.update) {
1444
+ console.log('');
1445
+ if (added.some((r) => r.name === 'liquidglass-tab')) {
1446
+ log('Next steps for liquidglass-tab:');
1447
+ console.log(' • Edit src/lib/tabs.ts — labels, icon presets, colors, screen text');
1448
+ console.log(` • Run: ${RUN} doctor`);
1449
+ console.log(' • Build a dev client: npx expo run:ios (iOS 26 shows real Liquid Glass,');
1450
+ console.log(' iOS 18 and older, Android and web get the glass fallback bar)');
1451
+ console.log(' • After package updates: npx @expokit/cli@latest update');
1452
+ }
1453
+ if (added.some((r) => r.name !== 'liquidglass-tab')) printRunHint(deps);
1454
+ }
1455
+ if (written.length && flags.update) {
1456
+ console.log('');
1457
+ if (added.some((r) => r.name === 'liquidglass-tab') && !flags.force) log('Your tabs.ts and tab screens were kept — only framework files refresh.');
1458
+ log('Reload Metro (or rebuild the native app) to see the changes.');
1459
+ }
1460
+ if (failed.length) {
1461
+ const verb = flags.update ? 'updated' : 'added';
1462
+ fail(`${failed.length} of ${results.length} item(s) not ${verb} (${failed.map((r) => r.name).join(', ')}).${added.length ? ' The others were.' : ''}`);
1092
1463
  }
1093
1464
  }
1094
1465
 
1095
- async function cmdUpdate(args, cwd) {
1096
- const { flags, positional } = parseFlags(args);
1097
- const name = positional[0];
1098
-
1466
+ /** Project root + components.json for a command that needs both. */
1467
+ function openProject(cwd, { needConfig }) {
1099
1468
  const projectRoot = findProjectRoot(cwd);
1100
1469
  if (!projectRoot) fail('Not inside an Expo project. Run from your app root.');
1470
+ if (needConfig && !fs.existsSync(path.join(projectRoot, 'components.json'))) fail(`No components.json — run: ${RUN} init`);
1471
+ return { projectRoot, config: loadConfig(projectRoot) };
1472
+ }
1101
1473
 
1102
- const configPath = path.join(projectRoot, 'components.json');
1103
- if (!fs.existsSync(configPath)) {
1104
- fail('No components.json — run: npx @expokit/cli@latest init');
1105
- }
1106
-
1107
- const config = loadConfig(projectRoot);
1108
- const pkgVersion = getPackageVersion();
1109
- const targets = name ? [resolveRegistryName(name)] : detectInstalledTemplates(projectRoot, config);
1474
+ async function cmdAdd(args, cwd) {
1475
+ const { flags, positional } = parseFlags(args);
1476
+ const names = [...new Set(positional.filter((p) => p !== '--list'))];
1110
1477
 
1111
- if (!targets.length) {
1112
- fail('No templates found. Run: npx @expokit/cli@latest add liquidglass-tab');
1478
+ if (!names.length) {
1479
+ listRegistry();
1480
+ return;
1113
1481
  }
1114
1482
 
1115
- if (flags.force) {
1116
- for (const resolved of targets) {
1117
- log(`Full overwrite ${resolved} (--force${flags.dryRun ? ', dry run' : ''})…\n`);
1118
- await cmdAdd([resolved, '--force', ...(flags.dryRun ? ['--dry-run'] : [])], cwd, { report: false });
1119
- }
1120
- return;
1483
+ const projectRoot = findProjectRoot(cwd);
1484
+ if (!projectRoot) fail('Not inside an Expo project. Run from your app root.');
1485
+
1486
+ if (!fs.existsSync(path.join(projectRoot, 'components.json'))) {
1487
+ warn('No components.json — running init first.');
1488
+ await cmdInit(cwd, flags, undefined, { theme: false });
1121
1489
  }
1122
1490
 
1123
- log(`Updating template files (expo-kit@${pkgVersion})…`);
1124
- log('Your src/lib/tabs.ts is kept — only framework files refresh.\n');
1491
+ await runBatch(names, projectRoot, loadConfig(projectRoot), flags, { report: true });
1492
+ }
1125
1493
 
1126
- for (const resolved of targets) {
1127
- if (resolved !== 'liquidglass-tab') {
1128
- warn(
1129
- `Smart update only supports liquidglass-tab today. For "${resolved}" run: npx @expokit/cli@latest add ${resolved} --force`
1130
- );
1131
- continue;
1132
- }
1133
- const { meta, filesRoot } = await loadRegistryItem(resolved);
1494
+ /**
1495
+ * `update [names]`: every installed item (or the named ones) brought to this CLI's version. Files the CLI wrote and
1496
+ * nobody touched refresh; a file you changed stops its item and shows the diff, unless --force takes upstream.
1497
+ */
1498
+ async function cmdUpdate(args, cwd) {
1499
+ const { flags, positional } = parseFlags(args);
1500
+ const { projectRoot, config } = openProject(cwd, { needConfig: true });
1501
+ const names = positional.length ? [...new Set(positional.map(resolveRegistryName))] : installedNames(projectRoot, config);
1502
+ if (!names.length) fail(`Nothing to update: components.json lists no installed items. Add one: ${RUN} add <name>`);
1503
+ log(`Updating ${names.join(', ')} to expo-kit@${getPackageVersion()}${flags.force ? ' (--force: your changes are replaced)' : ''}…`);
1504
+ await runBatch(names, projectRoot, config, { ...flags, update: true }, { report: false });
1505
+ }
1134
1506
 
1135
- if (flags.dryRun) {
1136
- log(`Dry run — ${meta.title} (${resolved})\n`);
1137
- } else {
1138
- log(`${meta.title} (${resolved})`);
1139
- }
1507
+ /** Files bigger than this aren't diffed (a component is a few dozen KB). */
1508
+ const MAX_DIFF_BYTES = 2 * 1024 * 1024;
1140
1509
 
1141
- copyRegistryFiles(filesRoot, projectRoot, config, LIQUIDGLASS_FRAMEWORK_PATHS, flags);
1142
- syncTabRoutes(projectRoot, config, flags);
1143
- ensureExpoRouterMain(projectRoot, flags.dryRun);
1510
+ /**
1511
+ * `diff [names]`: each file of an installed item against what was installed and what this CLI ships. With only a
1512
+ * hash on record, "as installed" can be shown when upstream hasn't moved since; otherwise yours vs upstream.
1513
+ */
1514
+ async function cmdDiff(args, cwd) {
1515
+ const { flags, positional } = parseFlags(args);
1516
+ const { projectRoot, config } = openProject(cwd, { needConfig: true });
1517
+ const names = positional.length ? [...new Set(positional.map(resolveRegistryName))] : installedNames(projectRoot, config);
1518
+ if (!names.length) fail(`Nothing to compare: components.json lists no installed items. Add one: ${RUN} add <name>`);
1519
+ const counts = { current: 0, pristine: 0, modified: 0, both: 0, untracked: 0, other: 0 };
1520
+ const show = (diff) => {
1521
+ for (const line of diffLines(diff)) console.log(` ${line}`);
1522
+ };
1144
1523
 
1145
- if (!flags.dryRun) {
1146
- markTemplateInstalled(projectRoot, config, resolved);
1524
+ for (const name of names) {
1525
+ const item = await loadRegistryItem(name);
1526
+ const { target } = targetFor(projectRoot, config, item.resolved, { ...flags, update: true });
1527
+ const entry = config.installed[item.resolved];
1528
+ console.log('');
1529
+ log(
1530
+ `${item.meta.title} (${item.resolved}) — ${entry?.version ? `installed with expo-kit ${entry.version}` : 'no install record'}` +
1531
+ `${entry?.kitVersion ? `, kit v${entry.kitVersion}` : ''}; this CLI is ${getPackageVersion()}`
1532
+ );
1533
+ for (const { src, dest, registryRel } of registryFiles(item.filesRoot, projectRoot, target)) {
1534
+ const rel = posixRel(projectRoot, dest);
1535
+ const say = (what) => console.log(` ${rel.padEnd(42)} ${what}`);
1536
+ const upstream = registryText(src, target);
1537
+ let stat = null;
1538
+ try {
1539
+ stat = fs.lstatSync(dest);
1540
+ } catch {
1541
+ // not there
1542
+ }
1543
+ if (!stat) {
1544
+ counts.other += 1;
1545
+ say('not in your project (update adds it)');
1546
+ continue;
1547
+ }
1548
+ if (!stat.isFile()) {
1549
+ counts.other += 1;
1550
+ say(`skipped — it's a ${stat.isSymbolicLink() ? 'symlink' : 'folder'}, not read`);
1551
+ continue;
1552
+ }
1553
+ if (stat.size > MAX_DIFF_BYTES) {
1554
+ counts.other += 1;
1555
+ say('skipped — larger than 2 MB');
1556
+ continue;
1557
+ }
1558
+ if (item.resolved === 'liquidglass-tab' && !LIQUIDGLASS_FRAMEWORK_PATHS.includes(registryRel)) {
1559
+ counts.other += 1;
1560
+ say('starter file — yours to edit; update keeps it');
1561
+ continue;
1562
+ }
1563
+ const have = fs.readFileSync(dest, 'utf8');
1564
+ const recorded = recordedHash(config.files, rel);
1565
+ const status = fileStatus(recorded, have, upstream);
1566
+ const both = status === 'modified' && recorded !== contentHash(upstream);
1567
+ counts[both ? 'both' : status] += 1;
1568
+ if (status === 'current') {
1569
+ say('up to date');
1570
+ } else if (status === 'pristine') {
1571
+ say('changed upstream — yours is untouched; update refreshes it');
1572
+ show(unifiedDiff(have, upstream, { from: `${rel} (yours, as installed)`, to: `${rel} (upstream)` }));
1573
+ } else if (status === 'modified' && !both) {
1574
+ say('changed by you');
1575
+ show(unifiedDiff(upstream, have, { from: `${rel} (as installed)`, to: `${rel} (yours)` }));
1576
+ } else if (status === 'modified') {
1577
+ say(`changed by you and upstream — update stops here (--force takes upstream)`);
1578
+ show(unifiedDiff(have, upstream, { from: `${rel} (yours)`, to: `${rel} (upstream)` }));
1579
+ } else {
1580
+ say('differs from upstream (no install record)');
1581
+ show(unifiedDiff(have, upstream, { from: `${rel} (yours)`, to: `${rel} (upstream)` }));
1582
+ }
1147
1583
  }
1148
1584
  }
1149
-
1150
1585
  console.log('');
1151
- log('Update complete. Reload Metro or rebuild the native app to see changes.');
1152
- log('To overwrite tabs.ts too: npx @expokit/cli@latest update --force');
1586
+ log(
1587
+ `${counts.current} up to date · ${counts.pristine} changed upstream · ${counts.modified} changed by you` +
1588
+ `${counts.both ? ` · ${counts.both} changed by you and upstream` : ''}` +
1589
+ `${counts.untracked ? ` · ${counts.untracked} without a record` : ''}${counts.other ? ` · ${counts.other} other` : ''}`
1590
+ );
1591
+ if (counts.pristine || counts.both || counts.untracked) console.log(` Bring in upstream: ${RUN} update ${positional.length ? names.join(' ') : ''}`.trimEnd());
1153
1592
  }
1154
1593
 
1155
1594
  function cmdDoctor(cwd) {
@@ -1178,14 +1617,14 @@ function cmdDoctor(cwd) {
1178
1617
  }
1179
1618
 
1180
1619
  const tabsPath = path.join(projectRoot, config.paths.lib ?? 'src/lib', 'tabs.ts');
1181
- const usesLiquidGlass = fs.existsSync(tabsPath) || config.installed?.['liquidglass-tab'];
1620
+ const usesLiquidGlass = fs.existsSync(tabsPath) || Object.hasOwn(config.installed, 'liquidglass-tab');
1182
1621
  if (usesLiquidGlass) {
1183
1622
  if (!fs.existsSync(tabsPath)) {
1184
1623
  warn(`No src/lib/tabs.ts — run: ${RUN} add liquidglass-tab`);
1185
1624
  issues += 1;
1186
1625
  } else {
1187
1626
  log(' ✓ src/lib/tabs.ts');
1188
- const installedVersion = config.installed?.['liquidglass-tab'];
1627
+ const installedVersion = config.installed['liquidglass-tab']?.version;
1189
1628
  const pkgVersion = getPackageVersion();
1190
1629
  if (installedVersion && installedVersion !== pkgVersion) {
1191
1630
  warn(
@@ -1307,9 +1746,10 @@ function printHelp() {
1307
1746
  expo-kit — copy-paste Expo UI (shadcn-style)
1308
1747
 
1309
1748
  Usage:
1310
- npx @expokit/cli@latest init [my-app] [--dry-run]
1311
- npx @expokit/cli@latest add <name...> [--dry-run] [--force]
1312
- npx @expokit/cli@latest update [name] [--dry-run] [--force]
1749
+ npx @expokit/cli@latest init [my-app] [--brand <hex>] [--font <name>] [--radius <pt>] [--scheme light|dark|system] [--yes] [--dry-run]
1750
+ npx @expokit/cli@latest add <name...> [--dry-run] [--force] [--dir <folder>] [--no-format]
1751
+ npx @expokit/cli@latest diff [name...]
1752
+ npx @expokit/cli@latest update [name...] [--dry-run] [--force]
1313
1753
  npx @expokit/cli@latest doctor
1314
1754
  npx @expokit/cli@latest add --list
1315
1755
  npx @expokit/cli@latest login
@@ -1320,17 +1760,35 @@ Usage:
1320
1760
 
1321
1761
  Examples:
1322
1762
  npx @expokit/cli@latest init my-app new Expo app with ExpoKit set up
1763
+ npx @expokit/cli@latest init --brand "#0ea5e9" --radius 16 --scheme dark
1323
1764
  npx @expokit/cli@latest add liquidglass-tab --dry-run
1324
1765
  npx @expokit/cli@latest add liquidglass-tab --force
1766
+ npx @expokit/cli@latest add toast skeleton bottom-sheet
1767
+ npx @expokit/cli@latest add skeleton --dir src/components/kit next to your own Skeleton.tsx
1768
+ npx @expokit/cli@latest diff toast your changes and upstream's, per file
1325
1769
  npx @expokit/cli@latest update
1326
- npx @expokit/cli@latest update liquidglass-tab
1770
+ npx @expokit/cli@latest update toast --force
1327
1771
  npx @expokit/cli@latest doctor
1328
1772
 
1329
1773
  Note: expo-kit copies files into your app (like shadcn). npm updates do NOT
1330
1774
  auto-sync — run \`update\` after upgrading the package.
1331
1775
 
1332
- \`update\` currently has smart refresh paths only for liquidglass-tab (framework
1333
- files; tabs.ts kept). Other items: use \`add <name> --force\` to overwrite.
1776
+ \`add\` with several names adds each one on its own (one that can't be added doesn't stop
1777
+ the rest), runs one \`npx expo install\` for packages package.json doesn't list yet,
1778
+ and formats the new files with your Prettier when the project has it (--no-format).
1779
+ It never replaces a file of yours that differs only by case (Skeleton.tsx vs
1780
+ skeleton.tsx), not even with --force: --dir <folder> puts the components somewhere
1781
+ else (inside the folder @/ points at) and points their imports there.
1782
+
1783
+ \`init\` asks for a brand colour, font, radius and light / dark (or takes the
1784
+ flags; --yes skips the questions), adds lib/kit.tsx and writes lib/kit-theme.tsx,
1785
+ an <AppTheme> wrapper around KitProvider for your root layout.
1786
+
1787
+ components.json remembers a hash of every file add / update wrote. \`diff\` shows
1788
+ what you changed since and what changed upstream. \`update\` (every installed
1789
+ item, or the ones named) refreshes the files nobody touched and stops on files
1790
+ you patched, showing the diff — \`--force\` takes upstream. liquidglass-tab keeps
1791
+ your tabs.ts and screens.
1334
1792
 
1335
1793
  Pro items (marked PRO in \`add --list\`): create a token at ${SITE_URL}/account,
1336
1794
  run \`login\` and paste it, then \`add\` them like any other item. In scripts, pipe
@@ -1354,7 +1812,7 @@ async function main(command, rest) {
1354
1812
  case 'init': {
1355
1813
  const { flags, positional } = parseFlags(rest);
1356
1814
  if (positional.length > 1) fail(`init takes one folder name: ${RUN} init my-app`);
1357
- cmdInit(process.cwd(), flags, positional[0]);
1815
+ await cmdInit(process.cwd(), flags, positional[0]);
1358
1816
  break;
1359
1817
  }
1360
1818
  case '--version':
@@ -1367,6 +1825,9 @@ async function main(command, rest) {
1367
1825
  case 'update':
1368
1826
  await cmdUpdate(rest, process.cwd());
1369
1827
  break;
1828
+ case 'diff':
1829
+ await cmdDiff(rest, process.cwd());
1830
+ break;
1370
1831
  case 'login':
1371
1832
  await cmdLogin(rest);
1372
1833
  break;
@@ -1389,7 +1850,7 @@ async function main(command, rest) {
1389
1850
  printHelp();
1390
1851
  break;
1391
1852
  default:
1392
- fail(`Unknown command "${command}". Use: init | add | update | doctor | login | logout | whoami | mcp`);
1853
+ fail(`Unknown command "${command}". Use: init | add | diff | update | doctor | login | logout | whoami | mcp`);
1393
1854
  }
1394
1855
  }
1395
1856
 
@@ -1397,6 +1858,6 @@ const [, , command, ...rest] = process.argv;
1397
1858
  try {
1398
1859
  await main(command, rest);
1399
1860
  } catch (e) {
1400
- if (e instanceof CliError) fail(e.message);
1401
- fail(`Unexpected error: ${e?.stack ?? e?.message ?? e}`);
1861
+ if (e instanceof CliError) die(e.message);
1862
+ die(`Unexpected error: ${e?.stack ?? e?.message ?? e}`);
1402
1863
  }